feat(tasks): add inherited context and resilient LLM execution

Add read-only task context inheritance, ordered task-level LLM failover, live Worker controls, cascading task cleanup, FIFO task concurrency, cancellation diagnostics, UI updates, tests, build script, and changelog.
This commit is contained in:
pphua committed 2026-08-20 18:34:24 +08:00
1 parent 7090b034f8
commit 9793b522d9
82 files changed
+11094 -1712

No files matched your search

+37
View File
@@ -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
+198
View File
@@ -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)
}
}
+49 -75
View File
@@ -6,25 +6,12 @@ import (
"fmt" "fmt"
) )
// AbortCause names WHY a planner/worker run's context was cancelled. // 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.
// 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.
type AbortCause struct { type AbortCause struct {
Code string // stable machine-readable id (logs / future filtering) Code string
Short string // 一句话,进折叠摘要行 Short string
Text string // 完整说明:谁取消的、为什么、这条意图接下来会怎样 Text string
} }
func (c *AbortCause) Error() string { return c.Text } 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} return &AbortCause{Code: code, Short: short, Text: text}
} }
// Causef builds an ad-hoc cause with runtime detail (e.g. which intent, which // Causef builds a cause that includes runtime-specific detail.
// deadline). Use the predeclared vars below when there is nothing dynamic to add.
func Causef(code, short, format string, args ...any) *AbortCause { func Causef(code, short, format string, args ...any) *AbortCause {
return &AbortCause{Code: code, Short: short, Text: fmt.Sprintf(format, args...)} return &AbortCause{Code: code, Short: short, Text: fmt.Sprintf(format, args...)}
} }
// The full enumeration of cancel sites, grouped by which context gets cancelled.
var ( var (
// ---- 任务级 exec ctx(Engine.cancelExec:取消该任务在跑的 planner + 全部 worker)---- // Task-level execution context.
AbortPausedByUser = cause("paused_by_user", "用户暂停了任务", AbortPausedByUser = cause("paused_by_user", "用户暂停了任务",
"用户暂停了任务(前端「暂停」按钮 / POST /api/tasks/{id}/state action=pause)。本次运行被主动取消,该意图已退回 frontier(open),恢复任务后会被重新领取、从头重跑") "用户通过任务控制接口(POST /api/tasks/{id}/control,action=pause)暂停了任务。本次 Planner/Worker 运行被主动取消;运行中的意图会退回 frontier(open),恢复任务后重新领取并从头执行")
AbortPausedByOrchestrator = cause("paused_by_orchestrator", "编排 Agent 暂停了任务",
AbortPausedByOrchestrator = cause("paused_by_orchestrator", "编排 agent 调用 pause_task 暂停了任务", "编排 Agent 调用了 pause_task 工具暂停本任务。本次 Planner/Worker 运行被主动取消;运行中的意图会退回 frontier(open),恢复后重新执行")
"编排 agent 调用了 pause_task 工具暂停本任务。本次运行被主动取消,该意图已退回 frontier(open),恢复后重跑")
AbortTaskDeleted = cause("task_deleted", "任务被删除", 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", "后端启动时恢复了「已暂停」状态", // Per-work context.
"后端启动时按库里持久化的「已暂停」状态恢复暂停。本次运行被取消(正常情况下此时并没有在跑的 run)") 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", // Main Agent and standalone conversation contexts.
"规划者判定任务目标已达成、任务落终态 done,随即取消所有在跑的 worker——它们手头的意图跑完也没意义了。该意图标记为 stopped,不是失败") 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 宽限用尽后强制取消", // Process-level and per-run hard backstop.
"任务级超时收尾的硬兜底:到达任务 timeout 后等在跑 worker 优雅收尾,超过 90s drain 宽限仍未结束 → 强制取消。该意图标记为 exhausted,收尾阶段通常已把事实/资产写回") AbortShutdown = cause("shutdown", "后端进程正在关闭",
"后端进程收到 SIGINT 或 SIGTERM,正在重启、更新或关闭。所有运行中的 Agent 会被取消;重启后残留的 running 意图会重置为 open 并重新执行")
// ---- 单个 work 的 ctx(Engine.workCancel[intentID])---- AbortRunHardTimeout = cause("run_hard_timeout", "单次运行的硬超时兜底已触发",
"单次运行超过软墙钟预算及额外宽限,说明模型请求或某个工具长时间没有返回,导致正常的回合边界收尾无法执行。请重点检查中断前最后一个未返回的工具调用")
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——多半是它挂住了")
) )
// AbortReason resolves the cause attached to a cancelled run context. ok=false // AbortReason resolves the named cause attached to a cancelled run context.
// means the ctx carries no cause at all (nothing was cancelled).
func AbortReason(ctx context.Context) (code, short, text string, ok bool) { func AbortReason(ctx context.Context) (code, short, text string, ok bool) {
c := context.Cause(ctx) c := context.Cause(ctx)
if c == nil { if c == nil {
return "", "", "", false 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 return ac.Code, ac.Short, ac.Text, true
} }
switch { switch {
case errors.Is(c, context.DeadlineExceeded): case errors.Is(c, context.DeadlineExceeded):
return "deadline_exceeded", "上游 context 到达 deadline(未挂具名原因)", return "deadline_exceeded", "上游 context 到达 deadline",
"上游 context 到达 deadline,但设置方没有用 WithTimeoutCause 挂具名原因: " + c.Error(), true "上游 context 到达 deadline,但设置方没有通过 WithTimeoutCause 附加具名原因: " + c.Error(), true
case errors.Is(c, context.Canceled): case errors.Is(c, context.Canceled):
// a plain Canceled means some cancel site still lacks a cause — say so return "canceled_no_cause", "取消方未附加具名原因",
// plainly instead of inventing one. "上游 context 被取消,但取消方没有通过 context.WithCancelCause 附加具名原因;请在 agent/cancelcause.go 登记原因并接入该取消点", true
return "canceled_no_cause", "取消方未挂具名原因(漏网的取消点)", default:
"上游 context 被取消,但取消方没有用 context.WithCancelCause 挂上具名原因。" + return "other", firstLine(c.Error(), 80), c.Error(), true
"这属于漏网的取消点——请在 agent/cancelcause.go 补一条枚举并接到该取消处", true
} }
return "other", firstLine(c.Error(), 80), c.Error(), true
} }
+4 -9
View File
@@ -6,10 +6,10 @@ import (
"time" "time"
"unicode/utf8" "unicode/utf8"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/norma/agentcore" "github.com/Autumn-27/norma/agentcore"
"github.com/Autumn-27/norma/harness" "github.com/Autumn-27/norma/harness"
"github.com/Autumn-27/norma/llm" "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 // 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 tkind = kind
tbuf.WriteString(text) 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()} lastTool := &runTrace{startedAt: time.Now()}
var finalText string 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) { for ev, err := range s.Prompt(ctx, input) {
if err != nil { if err != nil {
flush() 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) sum, detail := terminalText(ctx, &harness.Terminal{Reason: reason, Err: ctx.Err()}, lastTool)
rec(db.Activity{Kind: "result", Summary: firstLine(sum, 400), Detail: detail}) rec(db.Activity{Kind: "result", Summary: firstLine(sum, 400), Detail: detail})
return finalText, reason, ctx.Err() 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 flush() // flush any trailing thinking / non-final text
sum, detail := ev.Terminal.Text, ev.Terminal.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) sum, detail = terminalText(ctx, ev.Terminal, lastTool)
} }
u := ev.Terminal.Usage // cumulative token usage for this session u := ev.Terminal.Usage // cumulative token usage for this session
@@ -162,9 +159,7 @@ func blocksText(blocks []llm.ContentBlock) string {
return b.String() return b.String()
} }
// firstLine returns a single-line, length-capped preview for the summary column. // firstLine returns a single-line, rune-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 "恢�…".
func firstLine(s string, max int) string { func firstLine(s string, max int) string {
s = strings.TrimSpace(s) s = strings.TrimSpace(s)
if before, _, found := strings.Cut(s, "\n"); found { if before, _, found := strings.Cut(s, "\n"); found {
+10
View File
@@ -84,6 +84,16 @@ type GoalSpec struct {
// runtime). The returned specs are read back from the store so callers can emit // 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. // 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 { 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 { if prov == nil {
return nil return nil
} }
+16 -6
View File
@@ -21,6 +21,7 @@ type MainAgent struct {
model string model string
tx *transcript.Store // raw LLM conversation persistence (nil = off) tx *transcript.Store // raw LLM conversation persistence (nil = off)
window int // context window in tokens (for compaction) 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) maxTurns int // max agent turns per run (0 = unlimited)
proxyAddr string // recording proxy for WebFetch (empty = direct) proxyAddr string // recording proxy for WebFetch (empty = direct)
proxyCACert string // recording proxy's CA cert path (HTTPS verify) 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} 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 // 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). // cert it trusts to verify HTTPS through it (empty addr = direct).
func (m *MainAgent) SetProxy(addr, caCert string) { m.proxyAddr, m.proxyCACert = addr, caCert } 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.SetAssetStore(as, as.Companies())
} }
tsx.SetTaskID(taskID) tsx.SetTaskID(taskID)
tsx.SetNotify(notify) // add_hint wakes this task's planner (debounced) tsx.SetNotify(notify) // add_hint wakes this task's planner (debounced)
tsx.SetResumeTask(resume) // set_goals 新增目标 → 把已完成/暂停的任务拉回 running tsx.SetResumeTask(resume) // set_goals 新增目标 → 把已完成/暂停的任务拉回 running
tsx.SetNotifyGoal(notifyGoal) // set_goals 新增目标 → 给 planner 记一条「人新增了目标:…」触发 tsx.SetNotifyGoal(notifyGoal) // set_goals 新增目标 → 给 planner 记一条「人新增了目标:…」触发
// 领域工具 + 基础默认工具集(Read/Write/Edit/MultiEdit/LS/Glob/Grep/Bash) // 领域工具 + 基础默认工具集(Read/Write/Edit/MultiEdit/LS/Glob/Grep/Bash)
base := append(tsx.MainAgentTools(), actool.DefaultTools()...) base := append(tsx.MainAgentTools(), actool.DefaultTools()...)
tools, def, cleanup := AugmentTools(ctx, "mainagent", base) 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 BashEnv: proxyEnv(m.proxyAddr, m.proxyCACert), // Bash 子命令默认走代理+信任 CA
WorkingDir: mainDir, // 本任务工作目录 <workDir>/tasks/<taskID> WorkingDir: mainDir, // 本任务工作目录 <workDir>/tasks/<taskID>
ToolOutputDir: cmdOutDir(mainDir), ToolOutputDir: cmdOutDir(mainDir),
MaxTurns: m.maxTurns, // 0 = unlimited (configurable in agent management) MaxTurns: m.maxTurns, // 0 = unlimited (configurable in agent management)
Compaction: compactionConfig(m.window), // long chats stay within the window Compaction: compactionConfig(m.compactionWindow()), // long chats stay within the window
Todos: actool.NewTodoStore(), // 会话级临时待办(TodoWrite),纯规划用,退出即丢 Todos: actool.NewTodoStore(), // 会话级临时待办(TodoWrite),纯规划用,退出即丢
// 命中预算(步数)→ SDK 跑收尾:向用户输出一句进展总结。Prompt 与收尾轮数可后台编辑(默认 10 轮)。 // 命中预算(步数)→ SDK 跑收尾:向用户输出一句进展总结。Prompt 与收尾轮数可后台编辑(默认 10 轮)。
Settlement: wrapupSettlement("mainagent", nil), Settlement: wrapupSettlement("mainagent", nil),
} }
+16 -5
View File
@@ -24,6 +24,7 @@ type Planner struct {
model string model string
tx *transcript.Store // raw LLM conversation persistence (nil = off) tx *transcript.Store // raw LLM conversation persistence (nil = off)
window int // context window in tokens (for compaction) 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) maxTurns int // max agent turns per run (0 = unlimited)
killWork func(intentID int64) error // engine callback to terminate a running work (nil = off) 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) 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{}} 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 // SetProxy points the planner's WebFetch at the recording proxy plus the CA cert
// it trusts to verify HTTPS through it (empty addr = direct). // it trusts to verify HTTPS through it (empty addr = direct).
func (p *Planner) SetProxy(addr, caCert string) { p.proxyAddr, p.proxyCACert = addr, caCert } 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 // 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 // the planner looks first at the actual change instead of re-scanning the whole
// overview. Kind: // overview. Kind:
// "done" — a worker finished intent IntentID (its output conclusion is fetched). //
// "finding" — a worker reported a finding on intent IntentID (Detail = 摘要). // "done" — a worker finished intent IntentID (its output conclusion is fetched).
// "goal" — the human (via 主 agent 的 set_goals) added one OR MORE goals in a // "finding" — a worker reported a finding on intent IntentID (Detail = 摘要).
// single call (Goals = 本次新增的目标文本,1+ 条;set_goals 支持批量). // "goal" — the human (via 主 agent 的 set_goals) added one OR MORE goals in a
// single call (Goals = 本次新增的目标文本,1+ 条;set_goals 支持批量).
type TriggerEvent struct { type TriggerEvent struct {
Kind string Kind string
IntentID int64 IntentID int64
@@ -308,7 +319,7 @@ func (p *Planner) Plan(ctx context.Context, taskID int64, as *db.AssetStore, ts
ToolOutputDir: cmdOutDir(taskDir), ToolOutputDir: cmdOutDir(taskDir),
MaxTurns: p.maxTurns, // 0 = unlimited (configurable in agent management) MaxTurns: p.maxTurns, // 0 = unlimited (configurable in agent management)
MaxDuration: maxDur, // 0=不限;有 deadline 时=距 deadline 剩余 MaxDuration: maxDur, // 0=不限;有 deadline 时=距 deadline 剩余
Compaction: compactionConfig(p.window), Compaction: compactionConfig(p.compactionWindow()),
// 跨唤醒共享的规划待办:让串行链在多轮之间保留(session 是新的,store 不是)。 // 跨唤醒共享的规划待办:让串行链在多轮之间保留(session 是新的,store 不是)。
Todos: p.todoFor(ts.ID()), Todos: p.todoFor(ts.ID()),
// 命中【本轮】步数预算→ SDK 跑收尾:把本轮已想清楚的结论落地(该派的 add_intent、 // 命中【本轮】步数预算→ SDK 跑收尾:把本轮已想清楚的结论落地(该派的 add_intent、
+102 -5
View File
@@ -8,8 +8,14 @@
package agent package agent
import ( import (
"bytes"
"context" "context"
"fmt"
"io"
"net/http"
"net/url"
"os" "os"
"regexp"
"strings" "strings"
"time" "time"
@@ -167,12 +173,16 @@ func (c Config) Provider() string {
// limiter lives on the single provider instance — so planner + all workers + // limiter lives on the single provider instance — so planner + all workers +
// main agent (which share this provider) are bounded by one shared rate limit. // main agent (which share this provider) are bounded by one shared rate limit.
func (c Config) NewProvider() (llm.Provider, error) { func (c Config) NewProvider() (llm.Provider, error) {
client, err := quotaAwareHTTPClient(c.Proxy)
if err != nil {
return nil, err
}
lc := llm.Config{ lc := llm.Config{
Format: c.Format, Format: c.Format,
BaseURL: c.BaseURL, BaseURL: c.BaseURL,
APIKey: c.APIKey, APIKey: c.APIKey,
Model: c.Model, Model: c.Model,
Proxy: c.Proxy, HTTPClient: client,
} }
// Derive the provider thinking params from the single UI selector: // Derive the provider thinking params from the single UI selector:
// "" → omit both (provider default); "off" → disable; else → enable + effort. // "" → omit both (provider default); "off" → disable; else → enable + effort.
@@ -191,6 +201,93 @@ func (c Config) NewProvider() (llm.Provider, error) {
return llm.NewProvider(lc) 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/ // TestConnection makes a minimal real completion to verify the provider/model/
// endpoint/key actually work. Returns the round-trip latency. // endpoint/key actually work. Returns the round-trip latency.
func TestConnection(ctx context.Context, c Config) (time.Duration, error) { func TestConnection(ctx context.Context, c Config) (time.Duration, error) {
+131
View File
@@ -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)
}
}
+68 -67
View File
@@ -9,26 +9,15 @@ import (
"github.com/Autumn-27/norma/harness" "github.com/Autumn-27/norma/harness"
) )
// This file turns a run's terminal state into something a human can act on. // runTrace retains the latest tool call so an interrupted run can identify the
// // operation that was still in flight.
// 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.
type runTrace struct { type runTrace struct {
startedAt time.Time startedAt time.Time
id string // tool_use id of the most recent call id string
name string // its tool name name string
input string // its raw input JSON input string
at time.Time at time.Time
pending bool // true = no tool_result arrived for it yet pending bool
} }
func (t *runTrace) start(id, name, input string) { 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{ var reasonHint = map[harness.TerminalReason]string{
harness.ReasonCompleted: "模型主动结束了本轮(正常收场),但没有留下文字总结。事实/资产以工具调用为准,看上面的 tool_use 记录", harness.ReasonCompleted: "模型正常结束了本轮,但没有留下文字总结;事实和资产以本轮工具调用记录为准",
harness.ReasonMaxTurns: "撞到步数上限(MaxTurns):SDK 已自动跑了一轮收尾(settlement)把事实/资产写回,只是缺一段文字总结。" + harness.ReasonMaxTurns: "达到步数上限(MaxTurns):SDK 已执行收尾并写回事实和资产,意图会标记为 exhausted,供规划者换方向继续,而不是作为失败处理",
"意图标记为 exhausted——表示「这个方向试过但没做完」,规划者会据此换角度,不是失败", harness.ReasonTimeout: "达到单次运行的软墙钟预算(MaxDuration):SDK 已在回合边界收尾并写回事实和资产,意图会标记为 exhausted",
harness.ReasonTimeout: "撞到单次运行的软墙钟预算(MaxDuration):SDK 在回合边界优雅收尾并写回事实/资产,只是缺文字总结。" + harness.ReasonModelError: "模型或 API 调用失败(网络、鉴权、限流、供应商 5xx 等);引擎会按策略退避重试,重试用尽后意图标记为 blocked",
"意图标记为 exhausted,不是失败", harness.ReasonBlockingLimit: "上下文长度达到硬上限,请求在发出前被拦截;应收窄意图粒度或压缩工具返回",
harness.ReasonModelError: "模型/API 调用失败(网络、鉴权、限流、供应商 5xx 等)。引擎会退避后自动重跑几次;重试用尽仍失败则意图标记为 blocked", harness.ReasonPromptTooLong: "提示词过长且上下文压缩重试已经用尽,无法继续执行",
harness.ReasonBlockingLimit: "上下文长度撞硬上限,请求在发出前就被拦下。通常是单次运行塞进了过多工具输出——考虑收窄意图粒度或压缩工具返回", harness.ReasonImageError: "当前模型不支持本轮多模态内容;请切换支持视觉的模型或避免工具返回图片",
harness.ReasonPromptTooLong: "提示词过长且压缩(compaction)重试已用尽,无法继续。同样指向单次运行累积的上下文过大", harness.ReasonStopHookPrevented: "Stop 钩子阻止本轮结束,随后未能继续;请检查任务 Guard 规则是否过严",
harness.ReasonImageError: "多模态内容不被当前模型支持(截图/图片块)。换支持视觉的模型,或让工具别回图", harness.ReasonHookStopped: "工具或钩子主动停止继续执行,例如越界目标或禁用命令;请检查最后一条 tool_result 的拦截说明",
harness.ReasonStopHookPrevented: "Stop 钩子阻止了本轮结束(守卫判定还不能收场),且随后没能继续下去。" + harness.ReasonAbortedStreaming: "运行在模型输出流式生成阶段被取消",
"检查任务的 Guard 规则是否过严", harness.ReasonAbortedTools: "运行在工具执行阶段被取消",
harness.ReasonHookStopped: "某个工具/钩子主动叫停了继续执行(守卫拦截,如越界目标、禁用命令)。看上面最后一条 tool_result 里的拦截说明",
harness.ReasonAbortedStreaming: "运行在【模型输出流式生成阶段】被取消",
harness.ReasonAbortedTools: "运行在【工具执行阶段】被取消",
} }
// terminalText renders a terminal event with no final text into (summary, detail). // terminalText renders a terminal event with no final text into a compact summary
// summary is one self-contained line; detail is the expandable breakdown. // and a Markdown detail block.
func terminalText(ctx context.Context, term *harness.Terminal, tr *runTrace) (string, string) { func terminalText(ctx context.Context, term *harness.Terminal, tr *runTrace) (string, string) {
reason := term.Reason reason := term.Reason
aborted := reason == harness.ReasonAbortedStreaming || reason == harness.ReasonAbortedTools 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 var sum string
switch { if aborted {
case aborted:
_, short, _, ok := AbortReason(ctx) _, short, _, ok := AbortReason(ctx)
if !ok { if !ok {
short = "取不到取消原因(run 的 context 上没有挂 cause)" short = "未能取得取消原因"
} }
stage := "工具执行阶段" stage := "执行过程中"
if reason == harness.ReasonAbortedStreaming { switch reason {
case harness.ReasonAbortedStreaming:
stage = "模型输出阶段" stage = "模型输出阶段"
case harness.ReasonAbortedTools:
stage = "工具执行阶段"
} }
sum = "(运行被中断:" + short + ";停在" + stage + progressSuffix(term, tr) + ",未完成)" sum = "(运行被中断:" + short + ";停在" + stage + progressSuffix(term, tr) + ",未完成)"
case reason == harness.ReasonMaxTurns || reason == harness.ReasonTimeout: } else if reason == harness.ReasonMaxTurns || reason == harness.ReasonTimeout {
sum = "(达运行预算上限(" + string(reason) + "),已收尾写回事实" + progressSuffix(term, tr) + ";本次无文字总结)" sum = "(达到运行预算上限(" + string(reason) + "),已收尾写回事实" + progressSuffix(term, tr) + ";本次无文字总结)"
default: } else {
hint := reasonHint[reason] hint := terminalReasonHint(reason)
if hint == "" { sum = "(无文字总结,终态 " + terminalReasonLabel(reason) + ":" + firstLine(hint, 80) + ")"
hint = "未知终态(harness 新增了终态但这里还没登记,请补 reasonHint)"
}
sum = "(无文字总结,终态 " + string(reason) + ":" + firstLine(hint, 80) + ")"
} }
// ---- expandable detail ----
var b strings.Builder var b strings.Builder
b.WriteString(sum) b.WriteString(sum)
b.WriteString("\n\n") 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 { if aborted {
code, _, why, ok := AbortReason(ctx) code, _, why, ok := AbortReason(ctx)
if ok { if ok {
fmt.Fprintf(&b, "- **中断原因** (`%s`): %s\n", code, why) fmt.Fprintf(&b, "- **中断原因** (`%s`): %s\n", code, why)
} else { } else {
b.WriteString("- **中断原因**: 取不到——取消方没有用 context.WithCancelCause 挂上具名原因。" + b.WriteString("- **中断原因**: 无法取得;取消方可能没有通过 context.WithCancelCause 附加具名原因\n")
"这属于漏网的取消点,请补 agent/cancelcause.go 里的枚举\n")
} }
} }
if term.Err != nil { if term.Err != nil {
fmt.Fprintf(&b, "- **底层错误**: `%v`\n", term.Err) 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 { if term.Turns > 0 {
fmt.Fprintf(&b, "- **已执行**: %d 轮模型回合\n", term.Turns) 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", fmt.Fprintf(&b, "- **累计 token**: 输入 %d / 输出 %d / 缓存读 %d / 缓存写 %d\n",
u.InputTokens, u.OutputTokens, u.CacheReadTokens, u.CacheWriteTokens) u.InputTokens, u.OutputTokens, u.CacheReadTokens, u.CacheWriteTokens)
} }
if tr.name != "" { if tr.name == "" {
if tr.pending { b.WriteString("- **工具调用**: 本次运行还没有发出工具调用就结束了\n")
fmt.Fprintf(&b, "- **中断时正在执行的工具**: `%s`(已跑 %s,**未返回结果**) — 若是硬超时/长时间无进展,问题多半就在这一步\n ```\n %s\n ```\n", } else if tr.pending {
tr.name, roundDur(time.Since(tr.at)), firstLine(tr.input, 300)) fmt.Fprintf(&b, "- **中断时正在执行的工具**: `%s`(已运行 %s,**未返回结果**)\n\n ```json\n %s\n ```\n",
} else { tr.name, roundDur(time.Since(tr.at)), firstLine(tr.input, 300))
fmt.Fprintf(&b, "- **中断前最后一个工具**: `%s`(已正常返回)\n", tr.name)
}
} else { } else {
b.WriteString("- **工具调用**: 本次运行还没发出任何工具调用就结束了\n") fmt.Fprintf(&b, "- **中断前最后一个工具**: `%s`(已正常返回)\n", tr.name)
} }
return sum, b.String() return sum, b.String()
} }
// progressSuffix adds "已跑 N 轮 / 12m30s" to a summary line — how far the run got func terminalReasonLabel(reason harness.TerminalReason) string {
// before it died, which decides whether an abort cost anything worth re-running. 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 { func progressSuffix(term *harness.Terminal, tr *runTrace) string {
var parts []string var parts []string
if term.Turns > 0 { if term.Turns > 0 {
@@ -144,10 +146,9 @@ func progressSuffix(term *harness.Terminal, tr *runTrace) string {
if len(parts) == 0 { if len(parts) == 0 {
return "" 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 { func roundDur(d time.Duration) string {
switch { switch {
case d < time.Minute: case d < time.Minute:
+79 -82
View File
@@ -12,8 +12,6 @@ import (
"github.com/Autumn-27/norma/llm" "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) { func TestReasonHintCoversEveryTerminalReason(t *testing.T) {
all := []harness.TerminalReason{ all := []harness.TerminalReason{
harness.ReasonCompleted, harness.ReasonBlockingLimit, harness.ReasonImageError, harness.ReasonCompleted, harness.ReasonBlockingLimit, harness.ReasonImageError,
@@ -21,152 +19,151 @@ func TestReasonHintCoversEveryTerminalReason(t *testing.T) {
harness.ReasonPromptTooLong, harness.ReasonStopHookPrevented, harness.ReasonHookStopped, harness.ReasonPromptTooLong, harness.ReasonStopHookPrevented, harness.ReasonHookStopped,
harness.ReasonMaxTurns, harness.ReasonTimeout, harness.ReasonMaxTurns, harness.ReasonTimeout,
} }
for _, r := range all { for _, reason := range all {
if strings.TrimSpace(reasonHint[r]) == "" { if strings.TrimSpace(reasonHint[reason]) == "" {
t.Errorf("terminal reason %q 没有中文说明", r) 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) { func TestAbortCausePropagatesThroughRunContextChain(t *testing.T) {
type key struct{} type key struct{}
exec, cancelExec := context.WithCancelCause(context.Background()) execCtx, cancelExec := context.WithCancelCause(context.Background())
work, cancelWork := context.WithCancelCause(exec) workCtx, cancelWork := context.WithCancelCause(execCtx)
defer cancelWork(nil) defer cancelWork(nil)
valued := context.WithValue(work, key{}, "task-1") valued := context.WithValue(workCtx, key{}, "task-1")
run, cancelRun := context.WithTimeoutCause(valued, time.Hour, AbortRunHardTimeout) runCtx, cancelRun := context.WithTimeoutCause(valued, time.Hour, AbortRunHardTimeout)
defer cancelRun() defer cancelRun()
cancelExec(AbortPausedByUser) cancelExec(AbortPausedByUser)
code, _, text, ok := AbortReason(runCtx)
code, _, text, ok := AbortReason(run)
if !ok || code != "paused_by_user" { 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") { if !strings.Contains(text, "frontier") {
t.Errorf("原因文案没传下来: %q", text) t.Fatalf("cause detail did not propagate: %q", text)
} }
} }
func TestAbortReasonFallbacks(t *testing.T) { func TestAbortReasonFallbacks(t *testing.T) {
if _, _, _, ok := AbortReason(context.Background()); ok { 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()) plain, cancel := context.WithCancel(context.Background())
cancel() cancel()
code, _, _, ok := AbortReason(plain) if code, _, _, ok := AbortReason(plain); !ok || code != "canceled_no_cause" {
if !ok || code != "canceled_no_cause" { t.Fatalf("code=%q ok=%v, want canceled_no_cause", code, ok)
t.Errorf("code=%q ok=%v,期望 canceled_no_cause", code, ok)
} }
timed, cancelTimed := context.WithTimeout(context.Background(), time.Nanosecond)
timed, cancelT := context.WithTimeout(context.Background(), time.Nanosecond) defer cancelTimed()
defer cancelT()
<-timed.Done() <-timed.Done()
if code, _, _, _ := AbortReason(timed); code != "deadline_exceeded" { 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) { func TestTerminalTextAbortedNamesCauseAndHangingTool(t *testing.T) {
ctx, cancel := context.WithCancelCause(context.Background()) ctx, cancel := context.WithCancelCause(context.Background())
cancel(AbortKilledByPlanner) cancel(AbortKilledByPlanner)
trace := &runTrace{startedAt: time.Now().Add(-90 * time.Second)}
tr := &runTrace{startedAt: time.Now().Add(-90 * time.Second)} trace.start("tu_1", "Bash", `{"command":"nmap -p- 10.0.0.1"}`)
tr.start("tu_1", "Bash", `{"command":"nmap -p- 10.0.0.1"}`)
term := &harness.Terminal{ term := &harness.Terminal{
Reason: harness.ReasonAbortedTools, Reason: harness.ReasonAbortedTools,
Err: context.Canceled, Err: context.Canceled,
Turns: 7, Turns: 7,
Usage: llm.Usage{InputTokens: 1200, OutputTokens: 340}, Usage: llm.Usage{InputTokens: 1200, OutputTokens: 340},
} }
sum, detail := terminalText(ctx, term, tr) summary, detail := terminalText(ctx, term, trace)
if !strings.Contains(summary, "规划者") || strings.Contains(summary, "\n") {
if !strings.Contains(sum, "kill_work") { t.Fatalf("unexpected summary: %q", summary)
t.Errorf("摘要没点出取消方: %q", sum)
}
if strings.Contains(sum, "\n") {
t.Errorf("摘要必须是单行(前端按首行截断): %q", sum)
} }
for _, want := range []string{"killed_by_planner", "aborted_tools", "7 轮", "Bash", "未返回结果", "1200"} { for _, want := range []string{"killed_by_planner", "aborted_tools", "7 轮", "Bash", "未返回结果", "1200"} {
if !strings.Contains(detail, want) { 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) { func TestTerminalTextCompletedToolNotBlamed(t *testing.T) {
ctx, cancel := context.WithCancelCause(context.Background()) ctx, cancel := context.WithCancelCause(context.Background())
cancel(AbortShutdown) cancel(AbortShutdown)
tr := &runTrace{startedAt: time.Now()} trace := &runTrace{startedAt: time.Now()}
tr.start("tu_1", "Read", `{"path":"/etc/hosts"}`) trace.start("tu_1", "Read", `{"path":"/etc/hosts"}`)
tr.done("tu_1") trace.done("tu_1")
_, detail := terminalText(ctx, &harness.Terminal{Reason: harness.ReasonAbortedStreaming}, trace)
_, detail := terminalText(ctx, &harness.Terminal{Reason: harness.ReasonAbortedStreaming}, tr) if strings.Contains(detail, "未返回结果") || !strings.Contains(detail, "已正常返回") {
if strings.Contains(detail, "未返回结果") { t.Fatalf("completed tool was blamed:\n%s", detail)
t.Errorf("已返回的工具被误报为卡住:\n%s", detail)
}
if !strings.Contains(detail, "已正常返回") {
t.Errorf("详情没记录最后一个工具:\n%s", detail)
} }
} }
// non-abort terminals keep their own explanation instead of the interruption text.
func TestTerminalTextNonAbortReasons(t *testing.T) { func TestTerminalTextNonAbortReasons(t *testing.T) {
ctx := context.Background() trace := &runTrace{startedAt: time.Now()}
tr := &runTrace{startedAt: time.Now()} summary, _ := terminalText(context.Background(), &harness.Terminal{Reason: harness.ReasonMaxTurns}, trace)
if !strings.Contains(summary, "运行预算上限") || strings.Contains(summary, "中断") {
sum, _ := terminalText(ctx, &harness.Terminal{Reason: harness.ReasonMaxTurns}, tr) t.Fatalf("unexpected max_turns summary: %q", summary)
if !strings.Contains(sum, "达运行预算上限") || strings.Contains(sum, "中断") {
t.Errorf("max_turns 摘要不对: %q", sum)
} }
summary, detail := terminalText(context.Background(), &harness.Terminal{
sum, detail := terminalText(ctx, &harness.Terminal{ Reason: harness.ReasonModelError, Err: errors.New("429 rate limited"),
Reason: harness.ReasonModelError, Err: errors.New("429 rate limited")}, tr) }, trace)
if !strings.Contains(sum, "model_error") { if !strings.Contains(summary, "model_error") || !strings.Contains(detail, "429 rate limited") {
t.Errorf("model_error 摘要不对: %q", sum) t.Fatalf("model_error detail incomplete: %q\n%s", summary, detail)
}
if !strings.Contains(detail, "429 rate limited") {
t.Errorf("详情没带上底层错误:\n%s", 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) { func TestAbortCausesAreWellFormed(t *testing.T) {
all := []*AbortCause{ all := []*AbortCause{
AbortPausedByUser, AbortPausedByOrchestrator, AbortTaskDeleted, AbortPausedOnReload, AbortPausedByUser, AbortPausedByOrchestrator, AbortTaskDeleted, AbortPausedOnReload,
AbortGoalMet, AbortSettleDrainTimeout, AbortKilledByPlanner, AbortWorkFinished, AbortGoalMet, AbortSettleDrainTimeout, AbortKilledByPlanner, AbortWorkPausedByUser,
AbortPausedRaceGuard, AbortChatStoppedByUser, AbortChatTurnFinished, AbortWorkCancelledByUser, AbortWorkFinished, AbortPausedRaceGuard,
AbortChatStoppedByUser, AbortChatPausedWithTask, AbortChatTurnFinished,
AbortShutdown, AbortRunHardTimeout, AbortShutdown, AbortRunHardTimeout,
} }
seen := map[string]bool{} seen := map[string]bool{}
for _, c := range all { for _, abort := range all {
switch { switch {
case c.Code == "" || seen[c.Code]: case abort.Code == "" || seen[abort.Code]:
t.Errorf("code 缺失或重复: %q", c.Code) t.Errorf("missing or duplicate code: %q", abort.Code)
case c.Short == "" || utf8.RuneCountInString(c.Short) > 40: case abort.Short == "" || utf8.RuneCountInString(abort.Short) > 40:
t.Errorf("%s: Short 必须存在且 ≤40 字(进折叠摘要行): %q", c.Code, c.Short) t.Errorf("%s has invalid short text: %q", abort.Code, abort.Short)
case utf8.RuneCountInString(c.Text) <= utf8.RuneCountInString(c.Short): case utf8.RuneCountInString(abort.Text) <= utf8.RuneCountInString(abort.Short):
t.Errorf("%s: Text 应比 Short 更完整", c.Code) 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) { func TestFirstLineCapsByRuneNotByte(t *testing.T) {
got := firstLine(strings.Repeat("恢复", 10), 5) if got := firstLine(strings.Repeat("恢复", 10), 5); got != "恢复恢复恢…" {
if got != "恢复恢复恢…" { t.Fatalf("firstLine split a Unicode character: %q", got)
t.Errorf("firstLine 按字节截断了: %q", got)
} }
if got := firstLine("头\n尾", 100); got != "头" { if got := firstLine("头\n尾", 100); got != "头" {
t.Errorf("多行没截到首行: %q", got) t.Fatalf("firstLine did not stop at newline: %q", got)
} }
} }
+552 -145
View File
@@ -23,6 +23,10 @@ func compactIntents(ns []*db.Node, parentsOf, yieldsOf map[int64][]int64) []map[
var p map[string]any var p map[string]any
_ = json.Unmarshal(n.Payload, &p) _ = json.Unmarshal(n.Payload, &p)
m := map[string]any{"id": n.ID, "summary": p["summary"], "state": n.State} 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 // 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 // dedup; fall back to legacy payload keys (target_ids plural, then target_id
// single) so intents stored before the rename still surface their anchors. // 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 // the model needn't spend a turn calling the tool — every plan round starts with
// an empty context and always needs this first). // an empty context and always needs this first).
func (t *ToolSet) graphOverviewData() map[string]any { func (t *ToolSet) graphOverviewData() map[string]any {
{ out := map[string]any{}
out := map[string]any{} // goals summary folded in so the planner needn't call list_goals each round.
// goals summary folded in so the planner needn't call list_goals each round. goals, _ := t.ts.ListByKind(db.KindGoal, 100)
goals, _ := t.ts.ListByKind(db.KindGoal, 100) gsum := make([]map[string]any, 0, len(goals))
gsum := make([]map[string]any, 0, len(goals)) for _, g := range goals {
for _, g := range goals { var p map[string]any
var p map[string]any _ = json.Unmarshal(g.Payload, &p)
_ = json.Unmarshal(g.Payload, &p) gsum = append(gsum, map[string]any{"id": g.ID, "state": g.State, "text": p["text"]})
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 fr, _ := t.ts.Frontier(100)
// planner reads them every round when generating intents (否则只写不读). out["open_intents"] = compactIntents(fr, parentsOf, yieldsOf)
hints, _ := t.ts.ListByKind(db.KindHint, 50) all, _ := t.ts.ListByKind(db.KindIntent, 300)
hsum := make([]map[string]any, 0, len(hints)) var running, recentDone []*db.Node
for _, h := range hints { for _, n := range all {
var p map[string]any switch n.State {
_ = json.Unmarshal(h.Payload, &p) case "running":
hsum = append(hsum, map[string]any{"id": h.ID, "state": h.State, "text": p["text"]}) 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 out["running_intents"] = compactIntents(running, parentsOf, yieldsOf)
// derived_from — possibly several facts combined) and its yields (the out["recent_done_intents"] = compactIntents(recentDone, parentsOf, yieldsOf)
// facts/findings it produced). factFrom maps a fact → the intent that out["frontier_open"] = len(fr)
// produced it. This is the relationship layer the flat lists lacked. // findings (confirmed vulns) and facts (worker exploration results) are
edges, _ := t.ts.Edges(5000) // 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{} parentsOf := map[int64][]int64{}
yieldsOf := map[int64][]int64{} yieldsOf := map[int64][]int64{}
factFrom := map[int64]int64{} factFrom := map[int64]int64{}
for _, e := range edges { for _, edge := range edges {
switch e.Rel { switch edge.Rel {
case db.RelDerivedFrom, db.RelSpawns: // upstream: derived_from (fact/finding/intent→intent) or spawns (origin fact→goal, legacy begin→intent) case db.RelDerivedFrom, db.RelSpawns:
parentsOf[e.To] = append(parentsOf[e.To], e.From) parentsOf[edge.To] = append(parentsOf[edge.To], edge.From)
case db.RelYields: // intent --yields--> fact/finding case db.RelYields:
yieldsOf[e.From] = append(yieldsOf[e.From], e.To) yieldsOf[edge.From] = append(yieldsOf[edge.From], edge.To)
factFrom[e.To] = e.From factFrom[edge.To] = edge.From
} }
} }
fr, _ := t.ts.Frontier(100)
out["open_intents"] = compactIntents(fr, parentsOf, yieldsOf) goals, _ := ts.ListByKind(db.KindGoal, relatedOverviewMaxGoalsPerSource)
all, _ := t.ts.ListByKind(db.KindIntent, 300) goalSummary := make([]map[string]any, 0, len(goals))
var running, recentDone []*db.Node for _, goal := range goals {
for _, n := range all { var payload map[string]any
switch n.State { _ = json.Unmarshal(goal.Payload, &payload)
case "running": goalSummary = append(goalSummary, inheritedMap(map[string]any{
running = append(running, n) "id": goal.ID, "state": goal.State, "text": budget.take(payload["text"], 400),
case "done", "blocked", "exhausted": }, source.Task.TaskID))
if len(recentDone) < 15 { }
recentDone = append(recentDone, n) 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) recentFindings := make([]map[string]any, 0, len(findings))
out["recent_done_intents"] = compactIntents(recentDone, parentsOf, yieldsOf) for _, finding := range findings {
out["frontier_open"] = len(fr) entry := inheritedMap(compactFinding(finding), source.Task.TaskID)
// findings (confirmed vulns) and facts (worker exploration results) are entry["summary"] = budget.take(entry["summary"], 400)
// now distinct node kinds. recent_facts surfaces fact summaries (esp. recentFindings = append(recentFindings, entry)
// negative results) so the planner sees them in one call; full content }
// via node_detail(id). item["recent_findings"] = recentFindings
vulnNodes, _ := t.ts.ListByKind(db.KindFinding, 1000) recentFacts := make([]map[string]any, 0, len(facts))
factNodes, _ := t.ts.ListByKind(db.KindFact, 1000) // newest first for _, fact := range facts {
out["findings"] = len(vulnNodes) // 确认漏洞数(目标判定看它) m := inheritedMap(compactNode(fact), source.Task.TaskID)
out["facts"] = len(factNodes) // 探索事实/结论数(含否定结论) m["summary"] = budget.take(m["summary"], 400)
recentFacts := make([]map[string]any, 0, 20) if from := factFrom[fact.ID]; from > 0 && terminalIntent[from] {
for _, n := range factNodes { m["from_intent"] = from
if len(recentFacts) >= 20 {
break
} }
m := compactNode(n) var payload map[string]any
if from := factFrom[n.ID]; from > 0 { if json.Unmarshal(fact.Payload, &payload) == nil {
m["from_intent"] = from // 本事实由哪个意图产生 if confidence, ok := payload["confidence"].(string); ok && confidence != "" {
} m["confidence"] = confidence
// 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) recentFacts = append(recentFacts, m)
} }
out["recent_facts"] = recentFacts // 最近事实的 {id, summary, from_intent, confidence?},详情/证据用 node_detail(id) item["recent_facts"] = recentFacts
// the original task (root) so the planner always has it, not just the
// decomposed goals. recentDone := recentTerminalIntents(ts, relatedOverviewMaxIntentsPerTask)
if description, goal, err := t.ts.Root(); err == nil { for _, intent := range recentDone {
out["task"] = map[string]any{"description": description, "goal": goal} intent.Inherited = true
intent.SourceTaskID = source.Task.TaskID
} }
// coverage:粗略的资产测试覆盖度参考——范围(task_scope)内的资产里,被 fact 碰过的 intentResults := compactIntents(recentDone, parentsOf, yieldsOf)
// 占比 + by_type(按类型的 总数/已测)。要看未测的具体资产由 agent 按需调 list_untested_assets 自行判断。仅任务上下文有。 for i, intent := range recentDone {
if t.as != nil && t.ts != nil && t.taskID > 0 { intentResults[i]["summary"] = budget.take(intentResults[i]["summary"], 400)
if cov, err := t.as.TaskCoverage(t.taskID, t.ts.ID()); err == nil { acts, _, err := ts.ActivityPageForTerminalIntent(intent.ID, 0, 20)
m := map[string]any{ if err != nil {
"denominator": cov.Denominator, continue
"tested": cov.Tested, }
"by_type": cov.ByType, var resultSummary, textFallback string
"note": "coverage资产测试覆盖度(包括接口等各种相关资产),粗略估计、仅供参考:容器型资产/大量枚举会让它偏低,勿据此认为已测完;scope 是当前任务测试范围的根资产(root_domain/subdomain/ip/cidr/company 原始行),即覆盖度分母的边界、本任务授权/圈定的目标本身;可用 add_task_scope 增补范围、list_untested_assets 看未测的具体资产【通常不调用list_untested_assets,按照任务推进即可】;", for _, activity := range acts {
switch activity.Kind {
case "result":
resultSummary = activity.Summary
case "text":
textFallback = activity.Summary
} }
if cov.ScopeRows == 0 { }
m["pct"] = nil if resultSummary == "" {
m["status"] = "范围未锚定" resultSummary = textFallback
} else { }
m["pct"] = cov.Pct if resultSummary != "" {
} intentResults[i]["result_summary"] = budget.take(resultSummary, 800)
// 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
} }
} }
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 { func compactNode(n *db.Node) map[string]any {
var p map[string]any var p map[string]any
_ = json.Unmarshal(n.Payload, &p) _ = 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. // 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 var p map[string]any
_ = json.Unmarshal(n.Payload, &p) _ = json.Unmarshal(n.Payload, &p)
m := 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)
}
if vc, ok := p["vulnclass"]; ok && vc != nil && vc != "" { if vc, ok := p["vulnclass"]; ok && vc != nil && vc != "" {
m["vulnclass"] = vc m["vulnclass"] = vc
} }
@@ -417,16 +734,23 @@ func compactFinding(n *db.Node) map[string]any {
} }
func (t *ToolSet) listFindings() actool.CoreTool { 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{}), obj(map[string]any{}),
func(context.Context, json.RawMessage) (actool.Result, error) { func(context.Context, json.RawMessage) (actool.Result, error) {
f, _ := t.ts.ListByKind(db.KindFinding, 500) f, _ := t.ts.ListByKindWithSources(db.KindFinding, 500)
intentOf, _ := t.ts.FindingIntents() // finding id -> 产生它的 intent id intentOf, _ := t.ts.FindingIntentsWithSources() // finding id -> 产生它的 intent id
taskID := t.ts.ID() // exploration id = 任务 id(本 store 内所有 finding 同属) taskID := t.taskID
if taskID <= 0 {
taskID, _ = t.ts.TaskID()
}
out := make([]map[string]any, 0, len(f)) out := make([]map[string]any, 0, len(f))
for _, n := range f { for _, n := range f {
m := compactFinding(n) 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 { if iid, ok := intentOf[n.ID]; ok {
m["intent_id"] = iid m["intent_id"] = iid
} }
@@ -437,10 +761,10 @@ func (t *ToolSet) listFindings() actool.CoreTool {
} }
func (t *ToolSet) listFacts() 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{}), obj(map[string]any{}),
func(context.Context, json.RawMessage) (actool.Result, error) { 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)) out := make([]map[string]any, 0, len(f))
for _, n := range f { for _, n := range f {
out = append(out, compactNode(n)) out = append(out, compactNode(n))
@@ -450,7 +774,7 @@ func (t *ToolSet) listFacts() actool.CoreTool {
} }
func (t *ToolSet) nodeDetail() 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"), obj(map[string]any{"id": idp("探索图节点 id(非资产 id)")}, "id"),
func(_ context.Context, in json.RawMessage) (actool.Result, error) { func(_ context.Context, in json.RawMessage) (actool.Result, error) {
var a struct { var a struct {
@@ -461,7 +785,7 @@ func (t *ToolSet) nodeDetail() actool.CoreTool {
if id <= 0 { if id <= 0 {
return actool.Errorf("id 必填"), nil return actool.Errorf("id 必填"), nil
} }
n, err := t.ts.GetNode(id) n, err := t.ts.GetNodeWithSources(id)
if err != nil { if err != nil {
return actool.Errorf(err.Error()), nil return actool.Errorf(err.Error()), nil
} }
@@ -493,9 +817,9 @@ func (t *ToolSet) addOneIntent(it intentItem) (int64, error) {
// 先校验锚点(建节点前,避免坏锚点留下孤儿意图)。 // 先校验锚点(建节点前,避免坏锚点留下孤儿意图)。
parents := pidList(it.ParentIDs) parents := pidList(it.ParentIDs)
for _, pidv := range parents { for _, pidv := range parents {
n, err := t.ts.GetNode(pidv) n, err := t.ts.GetNodeWithSources(pidv)
if err != nil || n == nil { 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 { if n.Kind != db.KindFact && n.Kind != db.KindFinding {
return 0, fmt.Errorf("parent_id %d 是 %q 节点,不能作为意图锚点:意图只能锚在已确认的【事实(fact)/发现(finding)】上,不能挂在意图/目标/提示上;顶层全新方向请留空 parent_ids", pidv, n.Kind) 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 { if goal == 0 || ev == 0 {
return actool.Errorf("goal_id 和 evidence_id 必填"), nil 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.Link(ev, db.RelProves, goal)
_ = t.ts.SetNodeState(goal, "met") _ = t.ts.SetNodeState(goal, "met")
// 每标记一个目标 met,就检查本任务是否【所有目标】都已 met;若是,自动判定 // 每标记一个目标 met,就检查本任务是否【所有目标】都已 met;若是,自动判定
@@ -668,12 +1000,19 @@ func (t *ToolSet) addFinding() actool.CoreTool {
} }
var id int64 var id int64
if t.ts != nil { 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 var err error
id, err = t.ts.AddNode(db.KindFinding, payload, 9, "confirmed", t.worker, anchors) id, err = t.ts.AddNode(db.KindFinding, payload, 9, "confirmed", t.worker, anchors)
if err != nil { if err != nil {
return actool.Errorf(err.Error()), 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.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) _, _ = 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 != "" { if c := strings.TrimSpace(it.Confidence); c != "" {
payload["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). // 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)) id, err := t.ts.AddNode(db.KindFact, payload, 5, "confirmed", t.worker, pidList(it.AssetIDs))
if err != nil { if err != nil {
return 0, err return 0, err
} }
intent := pid(it.IntentID)
if intent <= 0 {
intent = defaultIntent
}
if intent > 0 { if intent > 0 {
_ = t.ts.Link(intent, db.RelYields, id) // chain: intent -> fact _ = t.ts.Link(intent, db.RelYields, id) // chain: intent -> fact
} }
@@ -984,6 +1329,10 @@ func (t *ToolSet) killWorkTool() actool.CoreTool {
if id <= 0 { if id <= 0 {
return actool.Errorf("intent_id 必填"), nil 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 { if err := t.killWork(id); err != nil {
return actool.Errorf(err.Error()), nil return actool.Errorf(err.Error()), nil
} }
@@ -1014,6 +1363,10 @@ func (t *ToolSet) steerWorkTool() actool.CoreTool {
if id <= 0 { if id <= 0 {
return actool.Errorf("intent_id 必填"), nil 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) == "" { if strings.TrimSpace(a.Message) == "" {
return actool.Errorf("message 必填"), nil 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. // getWorkerOutput returns a work's final (or截至中止时的) conclusion text by intent id.
func (t *ToolSet) getWorkerOutput() actool.CoreTool { 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"), obj(map[string]any{"intent_id": idp("意图 id(= work 句柄)")}, "intent_id"),
func(_ context.Context, in json.RawMessage) (actool.Result, error) { func(_ context.Context, in json.RawMessage) (actool.Result, error) {
var a struct { var a struct {
@@ -1037,7 +1390,14 @@ func (t *ToolSet) getWorkerOutput() actool.CoreTool {
if id <= 0 { if id <= 0 {
return actool.Errorf("intent_id 必填"), nil 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 { if err != nil {
return actool.Errorf(err.Error()), nil return actool.Errorf(err.Error()), nil
} }
@@ -1056,16 +1416,25 @@ func (t *ToolSet) getWorkerOutput() actool.CoreTool {
pick, terminated = fallback, true pick, terminated = fallback, true
} }
if pick == nil { 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 return actool.Text("(该 work 尚无任何输出)"), nil
} }
detail, _ := t.ts.ActivityDetail(pick.ID) detail, _ := t.ts.ActivityDetailWithSources(pick.ID)
if detail == "" { if detail == "" {
detail = pick.Summary detail = pick.Summary
} }
return jsonResult(map[string]any{ result := map[string]any{
"intent_id": id, "worker_name": pick.Worker, "final_text": detail, "intent_id": id, "worker_name": pick.Worker, "final_text": detail,
"summary": pick.Summary, "is_error": pick.IsError, "terminated": terminated, "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 { func traceSteps(acts []db.Activity) []map[string]any {
steps := make([]map[string]any, 0, len(acts)) steps := make([]map[string]any, 0, len(acts))
for i := range 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, "step_id": acts[i].ID, "kind": acts[i].Kind, "tool": acts[i].Tool,
"is_error": acts[i].IsError, "summary": firstLine(acts[i].Summary, 100), "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 return steps
} }
@@ -1092,7 +1465,7 @@ func (t *ToolSet) getWorkerTrace() actool.CoreTool {
"① 只传 intent_id → 返回该 work 每一步的摘要流(summary≤100字,含 step_id;只是动作轮廓,不含完整输出);\n"+ "① 只传 intent_id → 返回该 work 每一步的摘要流(summary≤100字,含 step_id;只是动作轮廓,不含完整输出);\n"+
"② intent_id + q → 只返回命中关键字的步骤摘要(在摘要和完整输出里都搜;仍只给 summary,要看内容用③);\n"+ "② intent_id + q → 只返回命中关键字的步骤摘要(在摘要和完整输出里都搜;仍只给 summary,要看内容用③);\n"+
"③ intent_id + step_ids(最多5个)→ 返回这些步骤的完整内容(detail)。\n"+ "③ intent_id + step_ids(最多5个)→ 返回这些步骤的完整内容(detail)。\n"+
"典型流程:先①/②定位可疑步骤的 step_id,再用③取其完整输出。不含思考(thinking)步骤。", "典型流程:先①/②定位可疑步骤的 step_id,再用③取其完整输出。不含思考(thinking)步骤。支持直接关联任务的历史 trace;其结果带 source_task_id/inherited=true 且只读。",
obj(map[string]any{ obj(map[string]any{
"intent_id": idp("意图 id(= work 句柄)"), "intent_id": idp("意图 id(= work 句柄)"),
"q": str("关键字:只返回摘要/完整输出命中它的步骤(可选;与 step_ids 互斥)"), "q": str("关键字:只返回摘要/完整输出命中它的步骤(可选;与 step_ids 互斥)"),
@@ -1111,6 +1484,13 @@ func (t *ToolSet) getWorkerTrace() actool.CoreTool {
if id <= 0 { if id <= 0 {
return actool.Errorf("intent_id 必填"), nil 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. // ③ detail drill-down by step ids (max 5), thinking excluded by the store.
if len(a.StepIDs) > 0 { if len(a.StepIDs) > 0 {
var ids []int64 var ids []int64
@@ -1122,31 +1502,47 @@ func (t *ToolSet) getWorkerTrace() actool.CoreTool {
if len(ids) > 5 { if len(ids) > 5 {
return actool.Errorf("step_ids 一次最多 5 个(先用不带 step_ids 的调用定位,再分批取)"), nil return actool.Errorf("step_ids 一次最多 5 个(先用不带 step_ids 的调用定位,再分批取)"), nil
} }
acts, err := t.ts.ActivityByIDs(ids) acts, err := t.ts.ActivityByIDsWithSources(ids)
if err != nil { if err != nil {
return actool.Errorf(err.Error()), nil return actool.Errorf(err.Error()), nil
} }
steps := make([]map[string]any, 0, len(acts)) steps := make([]map[string]any, 0, len(acts))
for i := range 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, "step_id": acts[i].ID, "kind": acts[i].Kind, "tool": acts[i].Tool,
"is_error": acts[i].IsError, "detail": acts[i].Detail, "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. // ①/② summary stream, optionally keyword-filtered; 100-char summaries.
var acts []db.Activity var acts []db.Activity
var err error var err error
if strings.TrimSpace(a.Q) != "" { 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 { } else {
acts, err = t.ts.ActivityTrace(id, a.Limit) acts, err = t.ts.ActivityTraceWithSources(id, a.Limit)
} }
if err != nil { if err != nil {
return actool.Errorf(err.Error()), 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 return actool.Errorf("q 必填"), nil
} }
// 排除调用者自身这条意图的步骤(worker 的自有 trace 已在其上下文里)。 // 排除调用者自身这条意图的步骤(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 { if err != nil {
return actool.Errorf(err.Error()), nil return actool.Errorf(err.Error()), nil
} }
@@ -1182,11 +1578,15 @@ func (t *ToolSet) searchAllWorkerTraces() actool.CoreTool {
if acts[i].NodeID != nil { if acts[i].NodeID != nil {
intent = *acts[i].NodeID 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, "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, "kind": acts[i].Kind, "tool": acts[i].Tool, "is_error": acts[i].IsError,
"summary": firstLine(acts[i].Summary, 100), "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}) return jsonResult(map[string]any{"query": a.Q, "hits": hits})
}) })
@@ -1217,13 +1617,16 @@ func (t *ToolSet) listWorkerTraces() actool.CoreTool {
if limit <= 0 { if limit <= 0 {
limit = 50 limit = 50
} }
all, err := t.ts.ListByKind(db.KindIntent, 500) all, err := t.ts.ListByKindWithSources(db.KindIntent, 500)
if err != nil { if err != nil {
return actool.Errorf(err.Error()), nil return actool.Errorf(err.Error()), nil
} }
q := strings.ToLower(strings.TrimSpace(a.Q)) q := strings.ToLower(strings.TrimSpace(a.Q))
out := make([]map[string]any, 0, limit) out := make([]map[string]any, 0, limit)
for _, n := range all { for _, n := range all {
if n.Inherited && n.State == "running" {
continue
}
switch n.State { switch n.State {
case "running", "done", "exhausted", "blocked", "stopped": // has run → has a process case "running", "done", "exhausted", "blocked", "stopped": // has run → has a process
default: default:
@@ -1235,7 +1638,11 @@ func (t *ToolSet) listWorkerTraces() actool.CoreTool {
if q != "" && !strings.Contains(strings.ToLower(summary), q) { if q != "" && !strings.Contains(strings.ToLower(summary), q) {
continue 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 { if len(out) >= limit {
break break
} }
+4 -5
View File
@@ -398,13 +398,12 @@ func (t *ToolSet) addTaskScope() actool.CoreTool {
) )
} }
// listUntestedAssets lets the plan agent pull the CURRENT TASK's in-scope, not-yet- // listUntestedAssets lets the plan agent pull the current + directly inherited
// tested assets on demand (filter by type, paginated) — so it can decide what to // scope's not-yet-tested assets on demand (filter by type, paginated).
// test next itself, instead of coverage pushing a backlog list into every prompt.
func (t *ToolSet) listUntestedAssets() actool.CoreTool { func (t *ToolSet) listUntestedAssets() actool.CoreTool {
return readTool( return readTool(
"list_untested_assets", "list_untested_assets",
"查询【本任务】范围内、还没被测过的资产(供你自己判断要不要补测,不代替你决策)。\n"+ "查询【本任务及直接关联任务】范围内、还没被事实锚点覆盖的资产(关联范围只读,供你自己判断要不要补测,不代替你决策)。\n"+
"可选按资产类型过滤:root_domain/subdomain/service/app/endpoint/ip。\n"+ "可选按资产类型过滤:root_domain/subdomain/service/app/endpoint/ip。\n"+
"分页:page 从 1 起、page_size 默认 10。返回 {assets:[{id,type,label}], total, page, page_size}。仅任务上下文可用。", "分页:page 从 1 起、page_size 默认 10。返回 {assets:[{id,type,label}], total, page, page_size}。仅任务上下文可用。",
obj(map[string]any{ obj(map[string]any{
@@ -432,7 +431,7 @@ func (t *ToolSet) listUntestedAssets() actool.CoreTool {
a.PageSize = 10 a.PageSize = 10
} }
offset := (a.Page - 1) * a.PageSize 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 { if err != nil {
return actool.Errorf(err.Error()), nil return actool.Errorf(err.Error()), nil
} }
+31
View File
@@ -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)
}
}
+12 -2
View File
@@ -49,6 +49,7 @@ type Worker struct {
mem *memory.Store // cross-engagement tradecraft memory (G4) mem *memory.Store // cross-engagement tradecraft memory (G4)
tx *transcript.Store // raw LLM conversation persistence (nil = off) tx *transcript.Store // raw LLM conversation persistence (nil = off)
window int // context window in tokens (for compaction) 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) maxTurns int // max agent turns per run (0 = unlimited)
// runTimeout is the wall-clock budget for the main exploration of one intent // 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 // (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} 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 // SetProxy configures the recording proxy address that workers route target
// traffic through, plus the CA cert path WebFetch trusts to verify HTTPS through // traffic through, plus the CA cert path WebFetch trusts to verify HTTPS through
// that MITM proxy. Empty addr disables the hint. // 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); // large tool output spills to cmd-output/ with a head + pointer (SDK tool.Capture);
// full output preserved on disk. 截断上限用 SDK 默认(30000 字符)。 // full output preserved on disk. 截断上限用 SDK 默认(30000 字符)。
ToolOutputDir: cmdOutDir(runDir), ToolOutputDir: cmdOutDir(runDir),
Compaction: compactionConfig(w.window), // long tool-heavy runs stay within the window Compaction: compactionConfig(w.compactionWindow()), // long tool-heavy runs stay within the window
Todos: actool.NewTodoStore(), // 会话级临时待办(TodoWrite),纯规划用,退出即丢 Todos: actool.NewTodoStore(), // 会话级临时待办(TodoWrite),纯规划用,退出即丢
} }
if hooks != nil { // typed-nil guard: only set when concrete (avoids harness panic) if hooks != nil { // typed-nil guard: only set when concrete (avoids harness panic)
opts.Hooks = hooks opts.Hooks = hooks
Executable
+65
View File
@@ -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
+16 -7
View File
@@ -73,14 +73,8 @@ func main() {
sigCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) sigCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop() defer stop()
// re-wrap with a named cause so a run cancelled by shutdown says "后端进程正在 ctx, shutdown := shutdownContext(sigCtx)
// 关停" in its trace instead of a bare context.Canceled (see agent/cancelcause.go).
ctx, shutdown := context.WithCancelCause(sigCtx)
defer shutdown(agent.AbortShutdown) defer shutdown(agent.AbortShutdown)
go func() {
<-sigCtx.Done()
shutdown(agent.AbortShutdown)
}()
mgr, err := server.NewManager(*dataDir, *proxy) mgr, err := server.NewManager(*dataDir, *proxy)
if err != nil { if err != nil {
@@ -113,3 +107,18 @@ func main() {
defer cancel() defer cancel()
_ = httpSrv.Shutdown(shutdownCtx) _ = 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
}
+19
View File
@@ -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)
}
}
+147 -49
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"net" "net"
"net/url" "net/url"
"sort"
"strings" "strings"
) )
@@ -913,7 +914,7 @@ func (s *AssetStore) CountByType(typ string) (int, error) {
} }
// QueryByCompany returns assets for a company, optionally filtered by type. // 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) { 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, q := `SELECT id, type, company_id, array_to_json(task_ids)::text,
COALESCE(domain,''), COALESCE(root_domain,''), COALESCE(ip,''), COALESCE(domain,''), COALESCE(root_domain,''), COALESCE(ip,''),
@@ -940,8 +941,6 @@ FROM assets WHERE company_id = $1`
return scanAssets(rows) 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) { func (s *AssetStore) CountByCompany(companyID int64, typ string) (int, error) {
q := `SELECT count(*) FROM assets WHERE company_id = $1` q := `SELECT count(*) FROM assets WHERE company_id = $1`
args := []any{companyID} args := []any{companyID}
@@ -954,8 +953,6 @@ func (s *AssetStore) CountByCompany(companyID int64, typ string) (int, error) {
return n, err 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 { func pageClause(args *[]any, limit, offset int) string {
q := ` ORDER BY last_seen DESC` q := ` ORDER BY last_seen DESC`
if limit > 0 { 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. // 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) { 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, q := `SELECT id, type, company_id, array_to_json(task_ids)::text,
COALESCE(domain,''), COALESCE(root_domain,''), COALESCE(ip,''), COALESCE(domain,''), COALESCE(root_domain,''), COALESCE(ip,''),
@@ -997,8 +993,6 @@ FROM assets WHERE $1 = ANY(task_ids)`
return scanAssets(rows) 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) { func (s *AssetStore) CountByTask(taskID int64, typ string) (int, error) {
q := `SELECT count(*) FROM assets WHERE $1 = ANY(task_ids)` q := `SELECT count(*) FROM assets WHERE $1 = ANY(task_ids)`
args := []any{taskID} args := []any{taskID}
@@ -1011,7 +1005,6 @@ func (s *AssetStore) CountByTask(taskID int64, typ string) (int, error) {
return n, err return n, err
} }
// CountsByTypeForTask returns asset counts per type for a single task.
func (s *AssetStore) CountsByTypeForTask(taskID int64) (map[string]int, error) { 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) rows, err := s.db.Query(`SELECT type, COUNT(*) FROM assets WHERE $1 = ANY(task_ids) GROUP BY type`, taskID)
if err != nil { if err != nil {
@@ -1021,6 +1014,151 @@ func (s *AssetStore) CountsByTypeForTask(taskID int64) (map[string]int, error) {
return scanTypeCounts(rows) 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, const assetSelectCols = `SELECT id, type, company_id, array_to_json(task_ids)::text,
COALESCE(domain,''), COALESCE(root_domain,''), COALESCE(ip,''), COALESCE(domain,''), COALESCE(root_domain,''), COALESCE(ip,''),
COALESCE(c_segment::text,''), port, COALESCE(c_segment::text,''), port,
@@ -1053,45 +1191,6 @@ func (s *AssetStore) GetByIDs(ids []int64) ([]*Asset, error) {
return scanAssets(rows) 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. // DeleteByCompanyID hard-deletes all assets belonging to a company. Returns rows deleted.
func (s *AssetStore) DeleteByCompanyID(companyID int64) (int64, error) { func (s *AssetStore) DeleteByCompanyID(companyID int64) (int64, error) {
res, err := s.db.Exec(`DELETE FROM assets WHERE company_id = $1`, companyID) 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) return scanTypeCounts(rows)
} }
// scanTypeCounts scans a (type, count) result set into a map.
func scanTypeCounts(rows *sql.Rows) (map[string]int, error) { func scanTypeCounts(rows *sql.Rows) (map[string]int, error) {
out := map[string]int{} out := map[string]int{}
for rows.Next() { for rows.Next() {
+5 -5
View File
@@ -290,12 +290,12 @@ func TestUpsertHTTPService(t *testing.T) {
sc := 200 sc := 200
cl := int64(1024) cl := int64(1024)
id1, err := av2.UpsertHTTPService(UpsertHTTPServiceReq{ id1, err := av2.UpsertHTTPService(UpsertHTTPServiceReq{
URL: "https://www.httptest.example.com/", URL: "https://www.httptest.example.com/",
Technologies: []string{"nginx", "vue"}, Technologies: []string{"nginx", "vue"},
StatusCode: &sc, StatusCode: &sc,
ContentLength: &cl, ContentLength: &cl,
PageTitle: "Test Site", PageTitle: "Test Site",
TaskID: 1, TaskID: 1,
}) })
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
+276 -5
View File
@@ -1,8 +1,11 @@
package db package db
import ( import (
"context"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors"
"fmt"
"time" "time"
) )
@@ -133,9 +136,236 @@ VALUES ($1,$2,NULLIF($3,''),NULLIF($4,''),$5,NULLIF($6,''),NULLIF($7,''),$8,$9,$
return p.ID, err 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 { func (d *DB) DeleteProfile(id int64) error {
_, err := d.Exec(`DELETE FROM llm_profiles WHERE id=$1`, id) return d.DeleteProfileContext(context.Background(), id)
return err }
// 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). // 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 { if _, err := tx.Exec(`UPDATE llm_profiles SET is_default=false WHERE is_default`); err != nil {
return err 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 return err
} }
if n, _ := res.RowsAffected(); n != 1 {
return ErrLLMProfileNotFound
}
return tx.Commit() 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 // 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. // global active profile. Precedence at runtime: agent binding → task/conv pin → active.
func (d *DB) SetAgentLLMProfile(key string, id *int64) error { func (d *DB) SetAgentLLMProfile(key string, id *int64) error {
_, err := d.Exec(`UPDATE agents SET llm_profile_id=$1 WHERE key=$2`, id, key) tx, err := d.Begin()
return err 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 // SetAgentWebSearch toggles whether an agent uses network search (still gated by
+14
View File
@@ -1,7 +1,9 @@
package db package db
import ( import (
"context"
"encoding/json" "encoding/json"
"errors"
"testing" "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) { func TestConfigStores(t *testing.T) {
d, err := Open(testDSN(t)) d, err := Open(testDSN(t))
if err != nil { if err != nil {
@@ -69,6 +80,9 @@ func TestConfigStores(t *testing.T) {
if err := d.SetActiveProfile(pid); err != nil { if err := d.SetActiveProfile(pid); err != nil {
t.Fatal(err) 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() act, err := d.ActiveProfile()
if err != nil || act == nil || act.APIKey != "secret123" { if err != nil || act == nil || act.APIKey != "secret123" {
t.Fatalf("active profile/key: %+v err=%v", act, err) t.Fatalf("active profile/key: %+v err=%v", act, err)
+46 -5
View File
@@ -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. // CreateConversation opens a new chat thread for agentKey with an initial title.
// llmProfileID may be nil to use the globally active profile. // llmProfileID may be nil to use the globally active profile.
func (d *DB) CreateConversation(agentKey, title string, llmProfileID *int64) (*Conversation, error) { func (d *DB) CreateConversation(agentKey, title string, llmProfileID *int64) (*Conversation, error) {
c, err := scanConv(d.QueryRow(` tx, err := d.Begin()
INSERT INTO conversations(agent_key, title, llm_profile_id) VALUES ($1, $2, $3)
RETURNING `+convCols, agentKey, title, llmProfileID))
if err != nil { if err != nil {
return nil, err 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 return &c, nil
} }
// UpdateConversationProfile sets (or clears) the LLM profile override for a conversation. // UpdateConversationProfile sets (or clears) the LLM profile override for a conversation.
func (d *DB) UpdateConversationProfile(id int64, llmProfileID *int64) error { func (d *DB) UpdateConversationProfile(id int64, llmProfileID *int64) error {
_, err := d.Exec(`UPDATE conversations SET llm_profile_id=$2 WHERE id=$1`, id, llmProfileID) tx, err := d.Begin()
return err 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. // ListConversations returns all threads, most-recently-updated first.
+334 -41
View File
@@ -25,34 +25,40 @@ func utf8Clean(s string) string {
// Node is a typed reasoning node (= old task_nodes). kind ∈ goal|intent|finding|hint. // Node is a typed reasoning node (= old task_nodes). kind ∈ goal|intent|finding|hint.
type Node struct { type Node struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Kind string `json:"kind"` Kind string `json:"kind"`
Payload json.RawMessage `json:"payload"` Payload json.RawMessage `json:"payload"`
Priority int `json:"priority"` Priority int `json:"priority"`
State string `json:"state"` State string `json:"state"`
Origin string `json:"origin,omitempty"` Origin string `json:"origin,omitempty"`
Owner string `json:"owner,omitempty"` Owner string `json:"owner,omitempty"`
Anchors []int64 `json:"anchors,omitempty"` BlockedReason string `json:"blocked_reason,omitempty"`
CreatedAt time.Time `json:"created_at"` 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). // Activity is one worker execution step (= old task_activity).
type Activity struct { type Activity struct {
ID int64 `json:"id"` ID int64 `json:"id"`
NodeID *int64 `json:"node_id,omitempty"` NodeID *int64 `json:"node_id,omitempty"`
Worker string `json:"worker,omitempty"` Worker string `json:"worker,omitempty"`
Kind string `json:"kind,omitempty"` Kind string `json:"kind,omitempty"`
Tool string `json:"tool,omitempty"` Tool string `json:"tool,omitempty"`
ToolUseID string `json:"tool_use_id,omitempty"` ToolUseID string `json:"tool_use_id,omitempty"`
IsError bool `json:"is_error"` IsError bool `json:"is_error"`
Summary string `json:"summary,omitempty"` Summary string `json:"summary,omitempty"`
Detail string `json:"-"` // full blob; lazy-loaded, omitted from list payloads Detail string `json:"-"` // full blob; lazy-loaded, omitted from list payloads
CreatedAt time.Time `json:"created_at"` Metadata json.RawMessage `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
// Token usage — set only on the terminal kind='result' record; nil elsewhere. // Token usage — set only on the terminal kind='result' record; nil elsewhere.
InputTokens *int `json:"input_tokens,omitempty"` InputTokens *int `json:"input_tokens,omitempty"`
OutputTokens *int `json:"output_tokens,omitempty"` OutputTokens *int `json:"output_tokens,omitempty"`
CacheReadTokens *int `json:"cache_read_tokens,omitempty"` CacheReadTokens *int `json:"cache_read_tokens,omitempty"`
CacheWriteTokens *int `json:"cache_write_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). // 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). // SetNodeState updates any node's state (never deletes).
func (s *ExplorationStore) SetNodeState(id int64, state string) error { 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 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 // 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 // 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 // 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. // forever in the UI. Workers re-run an intent from scratch, so reopening is safe.
func (s *ExplorationStore) ResetRunningIntents() (int64, error) { 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) WHERE exploration_id=$1 AND kind='intent' AND state='running'`, s.expID)
if err != nil { if err != nil {
return 0, err 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. // changed (false = intent absent or not in a rerunnable state). Used by the "重跑" button.
func (s *ExplorationStore) ReopenIntent(id int64) (bool, error) { func (s *ExplorationStore) ReopenIntent(id int64) (bool, error) {
res, err := s.db.Exec(`UPDATE exploration_nodes 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' WHERE id=$1 AND exploration_id=$2 AND kind='intent'
AND state IN ('blocked','exhausted','stopped')`, id, s.expID) AND state IN ('blocked','exhausted','stopped')`, id, s.expID)
if err != nil { 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 // (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. // number reopened. Workers re-run each from scratch; kept graph writes remain.
func (s *ExplorationStore) ReopenBlockedIntents() (int64, error) { 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) WHERE exploration_id=$1 AND kind='intent' AND state='blocked'`, s.expID)
if err != nil { if err != nil {
return 0, err 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 states stamp completed_at; reopening (back to open/running) clears it.
terminal := state == "done" || state == "blocked" || state == "exhausted" || state == "stopped" terminal := state == "done" || state == "blocked" || state == "exhausted" || state == "stopped"
_, err := s.db.Exec(`UPDATE exploration_nodes _, 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) WHERE id=$2 AND exploration_id=$3 AND kind='intent'`, state, id, s.expID, terminal)
return err 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) { func scanNode(sc interface{ Scan(...any) error }) (*Node, error) {
var n Node var n Node
var payload []byte 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 return nil, err
} }
n.Payload = json.RawMessage(payload) 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() 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). // Frontier returns open intents ordered by priority desc then id (FIFO tiebreak).
func (s *ExplorationStore) Frontier(limit int) ([]*Node, error) { func (s *ExplorationStore) Frontier(limit int) ([]*Node, error) {
if limit <= 0 { if limit <= 0 {
@@ -516,12 +622,16 @@ func (s *ExplorationStore) Stats() (map[string]int, error) {
// AppendActivity records one worker step and returns its id. // AppendActivity records one worker step and returns its id.
func (s *ExplorationStore) AppendActivity(a Activity) (int64, error) { func (s *ExplorationStore) AppendActivity(a Activity) (int64, error) {
metadata := a.Metadata
if len(metadata) == 0 {
metadata = json.RawMessage(`{}`)
}
var id int64 var id int64
err := s.db.QueryRow(` 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) 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) 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), 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 return id, err
} }
@@ -659,7 +769,7 @@ func (s *ExplorationStore) ActivityList(nodeID *int64, sinceID int64, limit int)
} }
var rows *sql.Rows var rows *sql.Rows
var err error 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 { if nodeID != nil {
rows, err = s.db.Query(`SELECT `+cols+` 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) 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() defer rows.Close()
out := []Activity{} out := []Activity{}
cursor := sinceID 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() { for rows.Next() {
var a Activity 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, 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 { if limit <= 0 {
limit = 200 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} args := []any{s.expID}
cond, cargs := f.cond(len(args) + 1) cond, cargs := f.cond(len(args) + 1)
args = append(args, cargs...) args = append(args, cargs...)
@@ -736,6 +883,51 @@ ORDER BY id DESC LIMIT $%d`, cond, beforeArg, beforeArg, limitArg)
} }
defer rows.Close() defer rows.Close()
desc := []Activity{} 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() { for rows.Next() {
var a Activity 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, 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 { if hasMore {
desc = desc[:limit] desc = desc[:limit]
} }
// reverse the newest-first window into ascending id order for display.
out := make([]Activity, len(desc)) out := make([]Activity, len(desc))
for i, a := range desc { for i, a := range desc {
out[len(desc)-1-i] = a out[len(desc)-1-i] = a
@@ -817,6 +1008,26 @@ ORDER BY id LIMIT $3`, s.expID, nodeID, limit)
return scanTrace(rows) 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 // ActivityTraceSearch finds steps whose summary OR detail matches q
// (case-insensitive), returning summaries only. nodeID != nil scopes to one work; // (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 // 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) 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 // AssetRef is a compact exploration node (intent/fact/finding) that references an
// asset via an anchor — for the coverage graph's node drawer. // asset via an anchor — for the coverage graph's node drawer.
type AssetRef struct { type AssetRef struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Kind string `json:"kind"` Kind string `json:"kind"`
State string `json:"state"` State string `json:"state"`
Summary string `json:"summary"` 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 // 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() 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 // AddStandaloneFinding writes a finding to the standalone findings table, which
// persists across task deletion (task_id / node_id become NULL when the task or // 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). // exploration node is deleted). taskID and nodeID may be 0 (stored as NULL).
+350
View File
@@ -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
}
+431
View File
@@ -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)
}
}
+135 -1
View File
@@ -1,6 +1,9 @@
package db package db
import "testing" import (
"fmt"
"testing"
)
func TestExplorationFlow(t *testing.T) { func TestExplorationFlow(t *testing.T) {
d, err := Open(testDSN(t)) d, err := Open(testDSN(t))
@@ -112,3 +115,134 @@ func TestExplorationFlow(t *testing.T) {
t.Fatalf("stats: %+v", st) 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)
}
+2 -1
View File
@@ -9,7 +9,8 @@ import (
"time" "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 { type DBFinding struct {
ID int64 ID int64
TaskID *int64 TaskID *int64
+79 -1
View File
@@ -129,19 +129,40 @@ CREATE TABLE IF NOT EXISTS exploration_nodes (
state TEXT NOT NULL DEFAULT 'open', state TEXT NOT NULL DEFAULT 'open',
origin TEXT, origin TEXT,
owner TEXT, owner TEXT,
blocked_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ, completed_at TIMESTAMPTZ,
CONSTRAINT ck_node_kind CHECK (kind IN ('begin','goal','intent','fact','finding','hint')), CONSTRAINT ck_node_kind CHECK (kind IN ('begin','goal','intent','fact','finding','hint')),
CONSTRAINT ck_node_state CHECK ( CONSTRAINT ck_node_state CHECK (
(kind='begin' AND state IN ('open')) OR (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='goal' AND state IN ('open','met','abandoned')) OR
(kind='fact' AND state IN ('confirmed','dismissed','origin')) OR (kind='fact' AND state IN ('confirmed','dismissed','origin')) OR
(kind='finding' AND state IN ('confirmed','dismissed')) OR (kind='finding' AND state IN ('confirmed','dismissed')) OR
(kind='hint' AND state IN ('active','consumed')) (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_part ON exploration_nodes(exploration_id, kind);
CREATE INDEX IF NOT EXISTS idx_expnodes_frontier ON exploration_nodes(exploration_id, priority DESC) CREATE INDEX IF NOT EXISTS idx_expnodes_frontier ON exploration_nodes(exploration_id, priority DESC)
WHERE kind='intent' AND state='open'; WHERE kind='intent' AND state='open';
@@ -179,12 +200,14 @@ CREATE TABLE IF NOT EXISTS activity (
is_error BOOLEAN NOT NULL DEFAULT false, is_error BOOLEAN NOT NULL DEFAULT false,
summary TEXT, summary TEXT,
detail TEXT, detail TEXT,
metadata JSONB NOT NULL DEFAULT '{}',
input_tokens INTEGER, input_tokens INTEGER,
output_tokens INTEGER, output_tokens INTEGER,
cache_read_tokens INTEGER, cache_read_tokens INTEGER,
cache_write_tokens INTEGER, cache_write_tokens INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT now() 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_node ON activity(exploration_id, node_id, id);
CREATE INDEX IF NOT EXISTS idx_act_since ON activity(exploration_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 -- 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, paused BOOLEAN NOT NULL DEFAULT false,
queued BOOLEAN NOT NULL DEFAULT false, queued BOOLEAN NOT NULL DEFAULT false,
llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL, 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, company_id BIGINT REFERENCES companies(id) ON DELETE SET NULL,
parent_ref TEXT, parent_ref TEXT,
timeout_seconds INTEGER NOT NULL DEFAULT 0, 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; ALTER TABLE tasks ADD COLUMN IF NOT EXISTS plan_heartbeat_seconds INTEGER NOT NULL DEFAULT 300;
-- 并发上限挂起态;补旧库。true=因并发上限排队、等待空位自动启动。 -- 并发上限挂起态;补旧库。true=因并发上限排队、等待空位自动启动。
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS queued BOOLEAN NOT NULL DEFAULT false; 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 显式插入的资产类型加保守范围 -- 自动填(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; ALTER TABLE agents ADD COLUMN IF NOT EXISTS trigger_max_parallel INTEGER NOT NULL DEFAULT 5;
-- per-agent LLM 绑定(agent 级默认模型):列自初版即在上方 CREATE 中,此 ALTER 仅为极旧库兜底(幂等)。 -- 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; 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; DROP TRIGGER IF EXISTS trg_agents_upd ON agents;
CREATE TRIGGER trg_agents_upd BEFORE UPDATE ON agents CREATE TRIGGER trg_agents_upd BEFORE UPDATE ON agents
FOR EACH ROW EXECUTE FUNCTION set_updated_at(); 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(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_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; DROP TRIGGER IF EXISTS trg_conversations_upd ON conversations;
CREATE TRIGGER trg_conversations_upd BEFORE UPDATE ON conversations CREATE TRIGGER trg_conversations_upd BEFORE UPDATE ON conversations
FOR EACH ROW EXECUTE FUNCTION set_updated_at(); FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+215
View File
@@ -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
}
+388
View File
@@ -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 = &copy
}
}
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, &currentRevision); 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
}
+527
View File
@@ -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")
}
}
+27
View File
@@ -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)
}
}
+210
View File
@@ -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
}
+7 -7
View File
@@ -347,23 +347,23 @@ func hostPortOf(n *CoverageGraphNode) (string, int) {
return host, port return host, port
} }
// BuildCoverageGraph assembles the full coverage graph for a task: every in-scope // BuildCoverageGraph assembles the full coverage graph for a task and its direct
// asset (all types) plus the connector root domains / companies needed to link // read-only sources: every in-scope asset plus connector root domains/companies,
// them, with a tested flag on each in-scope asset. The frontend does the folding // with current-or-source fact anchors reflected in Tested. The legacy expID
// and highlighting; this only returns nodes + derived containment edges. // argument is retained for API compatibility; the task registry is authoritative.
func (s *AssetStore) BuildCoverageGraph(taskID, expID int64) (*CoverageGraphData, error) { func (s *AssetStore) BuildCoverageGraph(taskID, _ int64) (*CoverageGraphData, error) {
g := &CoverageGraphData{Nodes: []CoverageGraphNode{}, Edges: []CoverageGraphEdge{}} g := &CoverageGraphData{Nodes: []CoverageGraphNode{}, Edges: []CoverageGraphEdge{}}
if taskID <= 0 { if taskID <= 0 {
return g, nil 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), SELECT a.id, a.type, COALESCE(a.company_id,0),
COALESCE(a.domain,''), COALESCE(a.root_domain,''), COALESCE(a.ip,''), COALESCE(a.domain,''), COALESCE(a.root_domain,''), COALESCE(a.ip,''),
COALESCE(a.url,''), COALESCE(a.port,0), COALESCE(a.service_type,''), COALESCE(a.url,''), COALESCE(a.port,0), COALESCE(a.service_type,''),
COALESCE(a.app_name,''), COALESCE(a.page_title,''), COALESCE(a.status_code,0), COALESCE(a.app_name,''), COALESCE(a.page_title,''), COALESCE(a.status_code,0),
(a.id IN (SELECT asset_id FROM tested)) AS tested (a.id IN (SELECT asset_id FROM tested)) AS tested
FROM assets a JOIN target t ON t.id = a.id 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 { if err != nil {
return nil, err return nil, err
} }
+20
View File
@@ -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)
}
}
+262 -34
View File
@@ -1,23 +1,34 @@
package db package db
import ( import (
"database/sql"
"encoding/json" "encoding/json"
"fmt"
"strconv"
"time" "time"
) )
// Task is a row in the task registry (1:1 with an exploration). // Task is a row in the task registry (1:1 with an exploration).
type Task struct { type Task struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Description string `json:"description"` Description string `json:"description"`
Goal string `json:"goal"` Goal string `json:"goal"`
ExplorationID int64 `json:"exploration_id"` ExplorationID int64 `json:"exploration_id"`
Status string `json:"status"` Status string `json:"status"`
Paused bool `json:"paused"` Paused bool `json:"paused"`
Queued bool `json:"queued"` // 因并发上限被挂起、等待空位自动启动;true=尚未开跑 Queued bool `json:"queued"`
LLMProfileID *int64 `json:"llm_profile_id,omitempty"` LLMProfileID *int64 `json:"llm_profile_id,omitempty"`
ParentRef string `json:"parent_ref,omitempty"` // 父任务 id(编排 spawn 记录;空=顶层) // Task-level ordered LLM chain. LLMProfileID remains the compatibility alias
CreatedAt time.Time `json:"created_at"` // for ActiveLLMProfileID while older API clients still send one profile id.
CompletedAt *time.Time `json:"completed_at,omitempty"` // 进入终态(done/failed/timeout)的时刻;非终态为 nil 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)。 // 任务级超时(见 docs/任务级超时与收尾设计.md)。
TimeoutSeconds int `json:"timeout_seconds"` // 0=不限时 TimeoutSeconds int `json:"timeout_seconds"` // 0=不限时
FirstRunAt *time.Time `json:"first_run_at,omitempty"` // 首次真正开始运行的时刻(非 created_at);nil=尚未运行 FirstRunAt *time.Time `json:"first_run_at,omitempty"` // 首次真正开始运行的时刻(非 created_at);nil=尚未运行
@@ -27,6 +38,23 @@ type Task struct {
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` 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. // IsTerminal reports whether a task status is a terminal (finished) state.
// 单一真源,替换散落各处的 done/failed 硬编码判定。 // 单一真源,替换散落各处的 done/failed 硬编码判定。
func IsTerminal(status string) bool { func IsTerminal(status string) bool {
@@ -40,6 +68,12 @@ func IsTerminal(status string) bool {
// 低于它(含缺省 0 / 负值 / 误配的小值)一律抬到 10min,防止把 planner 打爆。 // 低于它(含缺省 0 / 负值 / 误配的小值)一律抬到 10min,防止把 planner 打爆。
const MinPlanHeartbeatSeconds = 600 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 { func normalizeHeartbeat(sec int) int {
if sec < MinPlanHeartbeatSeconds { if sec < MinPlanHeartbeatSeconds {
return 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) { 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() tx, err := d.Begin()
if err != nil { if err != nil {
return nil, err 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 { VALUES ($1, 'fact', $2, 0, 'origin', 'system')`, expID, string(originPayload)); err != nil {
return nil, err return nil, err
} }
if timeoutSeconds < 0 { if opts.TimeoutSeconds < 0 {
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(` 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) INSERT INTO tasks(description, goal, exploration_id, llm_profile_id, active_llm_profile_id, timeout_seconds, plan_heartbeat_seconds)
RETURNING id, status, paused, created_at`, description, goal, expID, llmProfileID, timeoutSeconds, planHeartbeatSeconds).Scan(&t.ID, &t.Status, &t.Paused, &t.CreatedAt); err != nil { 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 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() 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) { func scanTask(sc interface{ Scan(...any) error }) (*Task, error) {
var t Task 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 nil, err
} }
return &t, nil return &t, nil
@@ -116,7 +230,16 @@ func (d *DB) ListTasks() ([]*Task, error) {
} }
out = append(out, t) 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). // 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 return nil, err
} }
if err := d.hydrateTaskContext(t); err != nil {
return nil, err
}
return t, nil return t, nil
} }
@@ -137,8 +263,6 @@ func (d *DB) SetPaused(id int64, paused bool) error {
return err 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 { func (d *DB) SetQueued(id int64, queued bool) error {
_, err := d.Exec(`UPDATE tasks SET queued=$1 WHERE id=$2`, queued, id) _, err := d.Exec(`UPDATE tasks SET queued=$1 WHERE id=$2`, queued, id)
return err return err
@@ -192,28 +316,132 @@ UPDATE tasks
return deadline, err return deadline, err
} }
// DeleteTask hard-deletes a task and its exploration subgraph (nodes/edges/ // DeleteTask preserves the historical behavior: remove the task and its
// activity via ON DELETE CASCADE). The task row is removed first so the // exploration subgraph while retaining global assets.
// ON DELETE RESTRICT on tasks.exploration_id does not block the exploration delete.
// Global assets are NOT touched.
func (d *DB) DeleteTask(id int64) error { 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() tx, err := d.Begin()
if err != nil { if err != nil {
return err return result, err
} }
defer tx.Rollback() defer tx.Rollback()
var expID int64 var expID int64
if err := tx.QueryRow(`SELECT exploration_id FROM tasks WHERE id=$1`, id).Scan(&expID); err != nil { if err := tx.QueryRow(`SELECT exploration_id FROM tasks WHERE id=$1 FOR UPDATE`, id).Scan(&expID); err != nil {
if err.Error() == "sql: no rows in result set" { if err == sql.ErrNoRows {
return nil // already gone 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 { 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 { 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()
} }
+409 -1
View File
@@ -1,6 +1,11 @@
package db package db
import "testing" import (
"fmt"
"sync"
"testing"
"time"
)
func TestTaskLifecycleAndDeleteCascade(t *testing.T) { func TestTaskLifecycleAndDeleteCascade(t *testing.T) {
d, err := Open(testDSN(t)) d, err := Open(testDSN(t))
@@ -75,3 +80,406 @@ func TestTaskLifecycleAndDeleteCascade(t *testing.T) {
t.Fatalf("exploration should be deleted, got %d", exps) 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")
}
}
+34 -7
View File
@@ -16,6 +16,27 @@ import (
"github.com/Autumn-27/artex/db" "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. // Recorder wraps an llm.Provider and records every completion call.
type Recorder struct { type Recorder struct {
inner llm.Provider inner llm.Provider
@@ -70,7 +91,13 @@ func (r *Recorder) Stream(ctx context.Context, req llm.CompletionRequest) iter.S
} }
start := time.Now() start := time.Now()
session := transcript.SessionIDFrom(ctx) 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. // Serialize the request for storage.
reqBody := serializeRequest(req) 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) { 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()) latency := int(time.Since(start).Milliseconds())
status := "ok" status := "ok"
@@ -169,11 +198,9 @@ func (r *Recorder) record(req llm.CompletionRequest, session, taskID, worker, re
ResponseBody: string(respBody), ResponseBody: string(respBody),
} }
go func() { if err := r.pg.InsertLLMRecord(rec); err != nil {
if err := r.pg.InsertLLMRecord(rec); err != nil { log.Printf("[llmrec] insert: %v", err)
log.Printf("[llmrec] insert: %v", err) }
}
}()
} }
// serializeRequest builds a JSON representation of the completion request. // serializeRequest builds a JSON representation of the completion request.
+29
View File
@@ -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)
}
}
+2 -5
View File
@@ -217,7 +217,6 @@ func (s *Server) reattribute(w http.ResponseWriter, r *http.Request) {
// GET /api/assets // GET /api/assets
// ===================================================================== // =====================================================================
// defaultAssetPageSize is the page size used when the caller omits ?limit.
const defaultAssetPageSize = 50 const defaultAssetPageSize = 50
func (s *Server) listAssets(w http.ResponseWriter, r *http.Request) { 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") typ := q.Get("type")
limit, _ := strconv.Atoi(q.Get("limit")) limit, _ := strconv.Atoi(q.Get("limit"))
offset, _ := strconv.Atoi(q.Get("offset")) 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 { if limit <= 0 {
limit = defaultAssetPageSize limit = defaultAssetPageSize
} }
@@ -351,8 +348,8 @@ func (s *Server) insertAssets(w http.ResponseWriter, r *http.Request) {
Assets []struct { Assets []struct {
Type string `json:"type"` Type string `json:"type"`
// root_domain / subdomain // root_domain / subdomain
Domain string `json:"domain"` Domain string `json:"domain"`
ICP string `json:"icp"` ICP string `json:"icp"`
RecordType string `json:"record_type"` RecordType string `json:"record_type"`
RecordValue []string `json:"record_value"` RecordValue []string `json:"record_value"`
// ip // ip
+13
View File
@@ -45,9 +45,11 @@ type chatAttachment struct {
// 文件先落这里,前端按返回的 abs 绝对路径写进任务描述) // 文件先落这里,前端按返回的 abs 绝对路径写进任务描述)
func (s *Server) chatUpload(w http.ResponseWriter, r *http.Request) { func (s *Server) chatUpload(w http.ResponseWriter, r *http.Request) {
var sub string var sub string
taskScoped := false
switch r.URL.Query().Get("scope") { switch r.URL.Query().Get("scope") {
case "task": case "task":
sub = "tasks" sub = "tasks"
taskScoped = true
case "session": case "session":
sub = "sessions" sub = "sessions"
case "staging": case "staging":
@@ -61,6 +63,17 @@ func (s *Server) chatUpload(w http.ResponseWriter, r *http.Request) {
writeErr(w, 400, "非法 id") writeErr(w, 400, "非法 id")
return 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") dir := filepath.Join(s.m.dir, sub, id, "uploads")
if err := os.MkdirAll(dir, 0o755); err != nil { if err := os.MkdirAll(dir, 0o755); err != nil {
writeErr(w, 500, "建目录失败: "+err.Error()) writeErr(w, 500, "建目录失败: "+err.Error())
+75 -3
View File
@@ -6,6 +6,8 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"path/filepath"
"strconv" "strconv"
"testing" "testing"
"time" "time"
@@ -92,15 +94,85 @@ func TestCoreTaskLifecyclePG(t *testing.T) {
t.Fatalf("list tasks: %d", code) t.Fatalf("list tasks: %d", code)
} }
// delete → cascade removes the exploration subgraph // The task detail header consumes the top-level engine mode while some clients
code, _ = doRetry("DELETE", "/api/tasks/"+id, nil) // 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 { if code != 200 {
t.Fatalf("delete task: %d", code) 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 { if got, _ := m.pg.GetTask(nid); got != nil {
t.Fatalf("task should be deleted from PG") 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 var n int
m.pg.QueryRow(`SELECT count(*) FROM exploration_nodes WHERE exploration_id=$1`, expID).Scan(&n) m.pg.QueryRow(`SELECT count(*) FROM exploration_nodes WHERE exploration_id=$1`, expID).Scan(&n)
if n != 0 { if n != 0 {
+114 -48
View File
@@ -30,21 +30,27 @@ func rawString(raw json.RawMessage) string {
// ---- Task (frontend "Task") ---- created_at as RFC3339, plus a derived status. // ---- Task (frontend "Task") ---- created_at as RFC3339, plus a derived status.
type TaskDTO struct { type TaskDTO struct {
ID string `json:"id"` ID string `json:"id"`
ExplorationID int64 `json:"exploration_id"` ExplorationID int64 `json:"exploration_id"`
Description string `json:"description"` Description string `json:"description"`
Goal string `json:"goal"` Goal string `json:"goal"`
Status string `json:"status"` // created | running | paused | done | failed Status string `json:"status"` // created | running | paused | done | failed
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
CreatedUnix int64 `json:"created_unix"` // created_at as unix seconds (for run-duration calc) 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 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) 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) LastActivity int64 `json:"last_activity_unix"` // unix seconds of the last activity (0 if none)
Paused bool `json:"paused"` Paused bool `json:"paused"`
Tokens TokenTotalDTO `json:"tokens"` // whole-task token consumption Queued bool `json:"queued"`
GoalsTotal int `json:"goals_total"` Tokens TokenTotalDTO `json:"tokens"` // whole-task token consumption
GoalsMet int `json:"goals_met"` GoalsTotal int `json:"goals_total"`
LLMProfileID *int64 `json:"llm_profile_id,omitempty"` // LLM profile used for this task; nil = default 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. // 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 { 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{ return TaskDTO{
ID: t.ID, ID: t.ID,
ExplorationID: t.ExpID, ExplorationID: t.ExpID,
Description: t.Description, Description: t.Description,
Goal: t.Goal, Goal: t.Goal,
Status: status, Status: status,
CreatedAt: rfc3339(time.Unix(t.CreatedAt, 0)), CreatedAt: rfc3339(time.Unix(t.CreatedAt, 0)),
CreatedUnix: t.CreatedAt, CreatedUnix: t.CreatedAt,
CompletedAt: completedRFC(t.CompletedAt), CompletedAt: completedRFC(t.CompletedAt),
CompletedUnix: t.CompletedAt, CompletedUnix: t.CompletedAt,
Paused: t.Paused, Paused: t.Paused,
LLMProfileID: t.LLMProfileID, 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 ---- // ---- TaskNode (frontend "TaskNode") — frontier, intents, exploration graph nodes ----
type TaskNodeDTO struct { type TaskNodeDTO struct {
ID string `json:"id"` ID string `json:"id"`
Type string `json:"type"` // db Node.Kind Type string `json:"type"` // db Node.Kind
Payload string `json:"payload,omitempty"` Payload string `json:"payload,omitempty"`
Priority int `json:"priority"` Priority int `json:"priority"`
State string `json:"state"` State string `json:"state"`
Origin string `json:"origin"` Origin string `json:"origin"`
TS string `json:"ts"` TS string `json:"ts"`
SourceTaskID string `json:"source_task_id,omitempty"`
Inherited bool `json:"inherited,omitempty"`
} }
func taskNodeDTO(n *db.Node) TaskNodeDTO { func taskNodeDTO(n *db.Node) TaskNodeDTO {
return TaskNodeDTO{ d := TaskNodeDTO{
ID: i64s(n.ID), ID: i64s(n.ID),
Type: n.Kind, Type: n.Kind,
Payload: rawString(n.Payload), Payload: rawString(n.Payload),
@@ -139,6 +159,11 @@ func taskNodeDTO(n *db.Node) TaskNodeDTO {
Origin: n.Origin, Origin: n.Origin,
TS: rfc3339(n.CreatedAt), 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 { func taskNodeDTOs(in []*db.Node) []TaskNodeDTO {
@@ -169,6 +194,27 @@ func edgeDTOs(in []db.Edge) []EdgeDTO {
return out 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") ---- // ---- Finding (frontend "Finding") ----
type FindingDTO struct { type FindingDTO struct {
@@ -186,6 +232,8 @@ type FindingDTO struct {
ParamID string `json:"param_id,omitempty"` ParamID string `json:"param_id,omitempty"`
TaskID string `json:"task_id,omitempty"` TaskID string `json:"task_id,omitempty"`
TaskDescription string `json:"task_description,omitempty"` TaskDescription string `json:"task_description,omitempty"`
SourceTaskID string `json:"source_task_id,omitempty"`
Inherited bool `json:"inherited,omitempty"`
Assets []FindingAssetDTO `json:"assets,omitempty"` Assets []FindingAssetDTO `json:"assets,omitempty"`
TS string `json:"ts"` TS string `json:"ts"`
} }
@@ -241,7 +289,7 @@ type findingPayload struct {
func findingDTO(n *db.Node) FindingDTO { func findingDTO(n *db.Node) FindingDTO {
var p findingPayload var p findingPayload
_ = json.Unmarshal(n.Payload, &p) _ = json.Unmarshal(n.Payload, &p)
return FindingDTO{ d := FindingDTO{
ID: i64s(n.ID), ID: i64s(n.ID),
VulnClass: p.VulnClass, VulnClass: p.VulnClass,
Name: p.Name, Name: p.Name,
@@ -251,6 +299,11 @@ func findingDTO(n *db.Node) FindingDTO {
Evidence: rawString(p.Evidence), Evidence: rawString(p.Evidence),
TS: rfc3339(n.CreatedAt), 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 // 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). // nodes with no row keep the 'pending' default and no finding_id (not editable).
// assets pre-resolves the anchored asset rows for label rendering. // 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 { 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)) out := make([]FindingDTO, 0, len(in))
for _, n := range in { for _, n := range in {
d := findingDTO(n) d := findingDTO(n)
d.TaskID = t.ID d.TaskID = taskID
d.TaskDescription = t.Description d.TaskDescription = description
if m, ok := meta[n.ID]; ok { if m, ok := meta[n.ID]; ok {
d.FindingID = i64s(m.ID) d.FindingID = i64s(m.ID)
d.Status = m.Status d.Status = m.Status
@@ -320,16 +377,19 @@ func findingFromDB(f *db.DBFinding, assets map[int64]*db.Asset) FindingDTO {
// ---- Activity (frontend "Activity") ---- // ---- Activity (frontend "Activity") ----
type ActivityDTO struct { type ActivityDTO struct {
Seq int64 `json:"seq"` // db Activity.ID Seq int64 `json:"seq"` // db Activity.ID
IntentID string `json:"intent_id,omitempty"` // db NodeID IntentID string `json:"intent_id,omitempty"` // db NodeID
Worker string `json:"worker"` Worker string `json:"worker"`
TS string `json:"ts"` // db CreatedAt TS string `json:"ts"` // db CreatedAt
Kind string `json:"kind"` Kind string `json:"kind"`
Tool string `json:"tool,omitempty"` Tool string `json:"tool,omitempty"`
ToolUseID string `json:"tool_use_id,omitempty"` ToolUseID string `json:"tool_use_id,omitempty"`
IsError bool `json:"is_error"` IsError bool `json:"is_error"`
Summary string `json:"summary"` Summary string `json:"summary"`
Detail string `json:"detail,omitempty"` 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. // token usage (set only on kind='result'); used for per-session token totals.
InputTokens *int `json:"input_tokens,omitempty"` InputTokens *int `json:"input_tokens,omitempty"`
OutputTokens *int `json:"output_tokens,omitempty"` OutputTokens *int `json:"output_tokens,omitempty"`
@@ -342,7 +402,7 @@ func activityDTO(a db.Activity) ActivityDTO {
if a.NodeID != nil { if a.NodeID != nil {
intent = i64s(*a.NodeID) intent = i64s(*a.NodeID)
} }
return ActivityDTO{ d := ActivityDTO{
Seq: a.ID, Seq: a.ID,
IntentID: intent, IntentID: intent,
Worker: a.Worker, Worker: a.Worker,
@@ -353,11 +413,17 @@ func activityDTO(a db.Activity) ActivityDTO {
IsError: a.IsError, IsError: a.IsError,
Summary: a.Summary, Summary: a.Summary,
Detail: a.Detail, // list endpoint leaves this empty (lazy) Detail: a.Detail, // list endpoint leaves this empty (lazy)
Metadata: a.Metadata,
InputTokens: a.InputTokens, InputTokens: a.InputTokens,
OutputTokens: a.OutputTokens, OutputTokens: a.OutputTokens,
CacheReadTokens: a.CacheReadTokens, CacheReadTokens: a.CacheReadTokens,
CacheWriteTokens: a.CacheWriteTokens, CacheWriteTokens: a.CacheWriteTokens,
} }
if a.SourceTaskID > 0 {
d.SourceTaskID = i64s(a.SourceTaskID)
}
d.Inherited = a.Inherited
return d
} }
func activityDTOs(in []db.Activity) []ActivityDTO { func activityDTOs(in []db.Activity) []ActivityDTO {
+427 -137
View File
@@ -73,6 +73,13 @@ const (
modelErrorRetryBackoff = 3 * time.Second // 每次重试前的退避 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 // 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 // (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; // 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) bc *Broadcaster // live activity pub/sub (SSE)
mu sync.RWMutex mu sync.RWMutex
planner *agent.Planner planner *agent.Planner
worker *agent.Worker worker *agent.Worker
started sync.Map // taskID -> bool, so Run is idempotent per task started sync.Map // taskID -> bool, so Run is idempotent per task
lastAct sync.Map // taskID -> int64 unix, last planner/worker activity (heartbeat) lastAct sync.Map // taskID -> int64 unix, last planner/worker activity (heartbeat)
paused sync.Map // taskID -> bool, user-paused (planner + workers idle but loops alive) llmCalls sync.Map // taskID -> *int64, actual planner/worker/main-agent LLM calls
dropCnt sync.Map // taskID -> *int64, running count of dropped (unpersistable) activity records 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, // 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 // so pausing can CANCEL an in-flight run (not just skip the next one). Recreated
// on resume since cancelling is one-shot. // on resume since cancelling is one-shot. Every cancellation carries a named
// CancelCauseFunc (not CancelFunc): every cancel site attaches a named // cause so the activity trace can identify the initiating control path.
// *agent.AbortCause so the run's trace can say WHICH of the ~10 cancel
// paths fired instead of one catch-all "运行被中断" line.
execMu sync.Mutex execMu sync.Mutex
execCancel map[string]context.CancelCauseFunc execCancel map[string]context.CancelCauseFunc
execCtx map[string]context.Context execCtx map[string]context.Context
// per-work (per running intent) cancel, so the planner's kill_work can stop a // Per-work control lets the planner kill a worker and lets the UI pause/cancel
// single worker without pausing the whole task. Keyed by globally-unique intent id. // one intent without pausing the whole task. The done channel closes only after
workMu sync.Mutex // runWorkerStep has stopped writing and committed its final state.
workCancel map[int64]context.CancelCauseFunc workMu sync.Mutex
work map[int64]*workExecution
// steerBox queues planner course-corrections for a running work (keyed by intent // 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 // 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) inflight sync.Map // taskID -> *int64, 在跑的 planner.Plan + worker.Execute 计数(用于 drain)
coordStarted sync.Map // taskID -> bool, deadline 协调器是否已启动(Run/reload 去重) coordStarted sync.Map // taskID -> bool, deadline 协调器是否已启动(Run/reload 去重)
// resolve, if set, returns a task's dedicated planner/worker when it pins a // resolve, if set, returns a task's dedicated planner/worker. In legacy mode
// specific LLM profile (nil,nil = use the global active pair). Set once at startup. // nil,nil falls back to the global pair; an authoritative resolver uses nil,nil
resolve func(t *Task) (*agent.Planner, *agent.Worker) // 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). // 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 // 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). // 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) { func (e *Engine) Pause(taskID string, cause error) {
e.paused.Store(taskID, true) e.paused.Store(taskID, true)
e.cancelExec(taskID, cause) 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 // 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 // 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 // sequence's hard-drain backstop.
// context.Cause and printed in its result record.
func (e *Engine) cancelExec(taskID string, cause error) { func (e *Engine) cancelExec(taskID string, cause error) {
e.execMu.Lock() e.execMu.Lock()
if cancel := e.execCancel[taskID]; cancel != nil { if cancel := e.execCancel[taskID]; cancel != nil {
@@ -206,32 +357,94 @@ func (e *Engine) LastActivity(taskID string) int64 {
return 0 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 (e *Engine) touch(taskID string) { e.lastAct.Store(taskID, time.Now().Unix()) }
func NewEngine(m *Manager) *Engine { func NewEngine(m *Manager) *Engine {
return &Engine{m: m, debounce: 800 * time.Millisecond, bc: NewBroadcaster(), return &Engine{m: m, debounce: 800 * time.Millisecond, bc: NewBroadcaster(),
execCancel: map[string]context.CancelCauseFunc{}, execCtx: map[string]context.Context{}, 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. // registerWork records the cancel for the work currently running intentID.
func (e *Engine) registerWork(intentID int64, cancel context.CancelCauseFunc) { func (e *Engine) registerWork(intentID int64, cancel context.CancelCauseFunc) {
e.workMu.Lock() e.workMu.Lock()
e.workCancel[intentID] = cancel e.work[intentID] = &workExecution{cancel: cancel, done: make(chan struct{})}
e.workMu.Unlock() e.workMu.Unlock()
} }
// unregisterWork removes and releases the work's cancel (called when Execute returns). // detachWork removes the live control handle once Execute has returned. complete
func (e *Engine) unregisterWork(intentID int64) { // 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() e.workMu.Lock()
if c := e.workCancel[intentID]; c != nil { run := e.work[intentID]
delete(e.workCancel, intentID) if run != nil {
c(agent.AbortWorkFinished) // release context resources (no-op if already cancelled) delete(e.work, intentID)
action = run.action
run.cancel(agent.AbortWorkFinished) // release resources (no-op if already cancelled)
} }
e.workMu.Unlock() e.workMu.Unlock()
e.steerMu.Lock() e.steerMu.Lock()
delete(e.steerBox, intentID) // drop any undelivered steering for a finished work delete(e.steerBox, intentID) // drop any undelivered steering for a finished work
e.steerMu.Unlock() 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 // 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("纠偏消息不能为空") return fmt.Errorf("纠偏消息不能为空")
} }
e.workMu.Lock() e.workMu.Lock()
running := e.workCancel[intentID] != nil running := e.work[intentID] != nil
e.workMu.Unlock() e.workMu.Unlock()
if !running { if !running {
return fmt.Errorf("意图 %d 当前没有运行中的 work(可能已结束或未被领取)", intentID) 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. // The work's agent-core session honors ctx cancellation and aborts promptly.
func (e *Engine) KillWork(intentID int64) error { func (e *Engine) KillWork(intentID int64) error {
e.workMu.Lock() e.workMu.Lock()
c := e.workCancel[intentID] run := e.work[intentID]
e.workMu.Unlock() e.workMu.Unlock()
if c == nil { if run == nil {
return fmt.Errorf("意图 %d 当前没有运行中的 work(可能已结束或未被领取)", intentID) return fmt.Errorf("意图 %d 当前没有运行中的 work(可能已结束或未被领取)", intentID)
} }
c(agent.AbortKilledByPlanner) run.cancel(agent.AbortKilledByPlanner)
return nil 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, // emitActivity persists one captured step AND fans it out to live subscribers,
// from a single point so storage and the SSE stream never diverge. // 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) id, err := e.appendActivity(t, r)
if err != nil { if err != nil {
// NO LONGER SILENT: dropping a record breaks command↔result pairing in the // 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", 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) t.ID, n, r.Worker, r.Kind, r.Tool, r.ToolUseID, dropReason(err), preview(r.Summary, 80), err, diag)
e.touch(t.ID) e.touch(t.ID)
return return r
} }
r.ID = id r.ID = id
if r.CreatedAt.IsZero() { if r.CreatedAt.IsZero() {
@@ -354,6 +567,7 @@ func (e *Engine) emitActivity(t *Task, r db.Activity) {
} }
e.bc.Publish(t.ID, r) e.bc.Publish(t.ID, r)
e.touch(t.ID) e.touch(t.ID)
return r
} }
// appendActivity persists one activity row, retrying briefly on write failure. // 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. // 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)) { func (e *Engine) SetAgentResolver(fn func(t *Task) (*agent.Planner, *agent.Worker)) {
e.resolve = fn 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 // 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). // pair. The resolver returning nil means "no override" (fall back to global).
func (e *Engine) snapshotFor(t *Task) (*agent.Planner, *agent.Worker) { func (e *Engine) snapshotFor(t *Task) (*agent.Planner, *agent.Worker) {
if e.resolve != nil { 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 return p, w
} }
} }
@@ -416,6 +640,14 @@ func (e *Engine) Ready() bool {
return p != nil && w != nil 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". // Mode reports "llm" when ready, else "idle".
func (e *Engine) Mode() string { func (e *Engine) Mode() string {
if e.Ready() { 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 // but no-op until an LLM is configured (so a task created while idle picks up
// automatically once LLM is set from the UI). // automatically once LLM is set from the UI).
func (e *Engine) Run(ctx context.Context, t *Task) { 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 { if _, loaded := e.started.LoadOrStore(t.ID, true); loaded {
e.deleteMu.RUnlock()
t.Notify() // already running — just nudge a planning round t.Notify() // already running — just nudge a planning round
return return
} }
rt := e.registerTaskRoutines(ctx, t.ID, 1+workers)
e.deleteMu.RUnlock()
e.touch(t.ID) e.touch(t.ID)
go e.plannerLoop(ctx, t) runTaskRoutine(rt, func(loopCtx context.Context) { e.plannerLoop(loopCtx, t) })
workers := e.m.Workers()
for i := 0; i < workers; i++ { 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;去重) e.startDeadlineCoordinator(ctx, t) // 任务级超时定时器(仅 timeout>0;去重)
// 仅在「完全没有活动意图(open+running)」时才 kick 首轮规划。带种子意图的任务:种子已 // 仅在「完全没有活动意图(open+running)」时才 kick 首轮规划。带种子意图的任务:种子已
@@ -497,6 +738,9 @@ func (e *Engine) plannerLoop(ctx context.Context, t *Task) {
if e.IsPaused(t.ID) { if e.IsPaused(t.ID) {
return // user-paused: don't plan 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 // 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 // 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). // 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) { if e.isSettling(t.ID) {
return return
} }
if !e.beginTaskOperation(t.ID) {
return
}
defer e.decInflight(t.ID)
e.stampFirstRun(t) // 首次真正规划 → 盖 first_run_at + 算 deadline(仅带 timeout 的任务) e.stampFirstRun(t) // 首次真正规划 → 盖 first_run_at + 算 deadline(仅带 timeout 的任务)
e.touch(t.ID) e.touch(t.ID)
emit := func(r db.Activity) { e.emitActivity(t, r) } emit := func(r db.Activity) { e.emitActivity(t, r) }
ectx := e.clockCtx(e.execContextFor(ctx, t.ID), t, false) // cancellable by Pause; 带任务 deadline 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) log.Printf("[planner] task %s 规划中…(%s 触发)", t.ID, src)
// round marker: each Plan() is one planner round; emit a boundary so the // round marker: each Plan() is one planner round; emit a boundary so the
// UI can separate rounds in the transcript (kind='round'). // 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 // what fired this round (worker done / finding; may be several — debounce
// coalesces a burst; empty for time/heartbeat wakes). // coalesces a burst; empty for time/heartbeat wakes).
triggers := t.drainTriggers() triggers := t.drainTriggers()
e.incInflight(t.ID)
taskIDInt, _ := strconv.ParseInt(t.ID, 10, 64) 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) 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 { switch {
case err != nil && ectx.Err() == nil: case err != nil && ectx.Err() == nil:
log.Printf("[planner] task %s 规划出错: %v", t.ID, err) 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 continue // user-paused: don't claim/execute intents
} }
if e.IsDeleting(t.ID) {
return
}
if e.isSettling(t.ID) { if e.isSettling(t.ID) {
if sleepCtx(ctx, 1000*time.Millisecond) { if sleepCtx(ctx, 1000*time.Millisecond) {
return return
@@ -590,108 +844,144 @@ func (e *Engine) workerLoop(ctx context.Context, t *Task, name string) {
} }
continue // 任务已终态(done/failed/timeout):停止领取遗留意图,别在完成后空跑 frontier continue // 任务已终态(done/failed/timeout):停止领取遗留意图,别在完成后空跑 frontier
} }
intent := e.claimNext(t, name) if !e.beginTaskOperation(t.ID) {
if intent == nil { return
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)
} }
claimed := e.runWorkerStep(ctx, t, name, worker)
e.decInflight(t.ID) e.decInflight(t.ID)
// CAPTURE kill state BEFORE unregisterWork cancels workCtx. kill = this work's if !claimed && sleepCtx(ctx, 800*time.Millisecond) {
// ctx was cancelled (planner kill_work) while the TASK ctx kept running; a return
// 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
} }
// 任务超时收尾的硬兜底 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) { func sleepCtx(ctx context.Context, d time.Duration) (done bool) {
select { select {
case <-ctx.Done(): case <-ctx.Done():
+54 -41
View File
@@ -7,90 +7,103 @@ import (
"github.com/Autumn-27/artex/agent" "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) { func TestPauseCancelsWithNamedCause(t *testing.T) {
e := NewEngine(nil) e := NewEngine(nil)
ectx := e.execContextFor(context.Background(), "t1") execCtx := e.execContextFor(context.Background(), "t1")
// the per-work child the worker loop actually runs under workCtx, workCancel := context.WithCancelCause(execCtx)
workCtx, workCancel := context.WithCancelCause(ectx)
defer workCancel(nil) defer workCancel(nil)
e.Pause("t1", agent.AbortPausedByUser) e.Pause("t1", agent.AbortPausedByUser)
if code, _, _, ok := agent.AbortReason(workCtx); !ok || code != "paused_by_user" { 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) { func TestKillWorkCancelsOnlyThatWorkWithNamedCause(t *testing.T) {
e := NewEngine(nil) e := NewEngine(nil)
ectx := e.execContextFor(context.Background(), "t1") execCtx := e.execContextFor(context.Background(), "t1")
workCtx, workCancel := context.WithCancelCause(ectx) workCtx, workCancel := context.WithCancelCause(execCtx)
e.registerWork(42, workCancel) e.registerWork(42, workCancel)
if err := e.KillWork(42); err != nil { 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" { 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 execCtx.Err() != nil {
if ectx.Err() != nil { t.Fatal("kill_work cancelled the entire task context")
t.Error("kill_work 不该取消整个任务的 exec ctx")
}
if err := e.KillWork(999); err == nil {
t.Error("对没有在跑的意图 KillWork 应该报错")
} }
_, complete := e.detachWork(42)
complete()
} }
func TestSettleDrainAndGoalMetCausesAreDistinct(t *testing.T) { func TestControlWorkCarriesUserActionCause(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
name string action string
cause *agent.AbortCause code string
want string
}{ }{
{"settle", agent.AbortSettleDrainTimeout, "settle_drain_timeout"}, {action: "pause", code: "work_paused_by_user"},
{"goalMet", agent.AbortGoalMet, "goal_met"}, {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) e := NewEngine(nil)
ectx := e.execContextFor(context.Background(), "t1") ctx, cancel := context.WithCancelCause(context.Background())
e.cancelExec("t1", tc.cause) e.registerWork(42, cancel)
if code, _, _, _ := agent.AbortReason(ectx); code != tc.want { done := make(chan error, 1)
t.Fatalf("code=%q,期望 %q", code, tc.want) 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 func TestSettleDrainAndGoalMetCausesAreDistinct(t *testing.T) {
// still SAYS why — this is the claim→Execute race guard, easily mistaken for a for _, tc := range []struct {
// mystery abort. 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) { func TestExecContextWhilePausedCarriesRaceGuardCause(t *testing.T) {
e := NewEngine(nil) e := NewEngine(nil)
e.Pause("t1", agent.AbortPausedByUser) e.Pause("t1", agent.AbortPausedByUser)
ctx := e.execContextFor(context.Background(), "t1") ctx := e.execContextFor(context.Background(), "t1")
if ctx.Err() == nil { 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" { 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) { func TestResumeYieldsFreshContext(t *testing.T) {
e := NewEngine(nil) e := NewEngine(nil)
old := e.execContextFor(context.Background(), "t1") old := e.execContextFor(context.Background(), "t1")
e.Pause("t1", agent.AbortPausedByUser) e.Pause("t1", agent.AbortPausedByUser)
if old.Err() == nil { 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 { if fresh := e.execContextFor(context.Background(), "t1"); fresh.Err() != nil {
t.Fatal("恢复后应拿到全新可用的 exec ctx") t.Fatal("resume did not create a fresh execution context")
} }
} }
+43
View File
@@ -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)
}
}
+42 -7
View File
@@ -43,7 +43,20 @@ func (e *Engine) inflightCounter(taskID string) *int64 {
v, _ := e.inflight.LoadOrStore(taskID, new(int64)) v, _ := e.inflight.LoadOrStore(taskID, new(int64))
return v.(*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) decInflight(taskID string) { atomic.AddInt64(e.inflightCounter(taskID), -1) }
func (e *Engine) inflightCount(taskID string) int64 { func (e *Engine) inflightCount(taskID string) int64 {
return atomic.LoadInt64(e.inflightCounter(taskID)) 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 { if t == nil || t.TimeoutSeconds <= 0 {
return return
} }
if _, loaded := e.coordStarted.LoadOrStore(t.ID, true); loaded { e.deleteMu.RLock()
if e.IsDeleting(t.ID) {
e.deleteMu.RUnlock()
return 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 // 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(任务超时词,最后目标判定,不产新意图)。 // ⑤ 终局一轮 planner(任务超时词,最后目标判定,不产新意图)。
met := e.runFinalPlannerRound(ctx, t) met := e.runFinalPlannerRound(ctx, t)
if !e.beginTaskOperation(t.ID) {
return
}
defer e.decInflight(t.ID)
// ⑥ 定终态(带守卫):met → done(completed);否则 timeout。若常规路径已先落 done, // ⑥ 定终态(带守卫):met → done(completed);否则 timeout。若常规路径已先落 done,
// 守卫(SetTaskStatusGuarded)会拒绝覆盖,保留 completed 语义。 // 守卫(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 // 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. // 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) { func (e *Engine) runFinalPlannerRound(ctx context.Context, t *Task) (met bool) {
if e.IsDeleting(t.ID) {
return false
}
planner, _ := e.snapshotFor(t) planner, _ := e.snapshotFor(t)
for planner == nil { for planner == nil {
if sleepCtx(ctx, deadlinePollInterval) { 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)) { if isTerminalStatus(e.m.TaskStatus(t.ID)) {
return false return false
} }
if e.IsDeleting(t.ID) {
return false
}
planner, _ = e.snapshotFor(t) 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) } emit := func(r db.Activity) { e.emitActivity(t, r) }
e.emitActivity(t, db.Activity{Worker: "planner", Kind: "round", e.emitActivity(t, db.Activity{Worker: "planner", Kind: "round",
Summary: fmt.Sprintf("任务超时收尾·终局判定(第 %d 轮)", e.nextPlannerRound(t.ID))}) 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) 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) 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 { if err != nil {
log.Printf("[deadline] task %s 终局规划出错: %v", t.ID, err) log.Printf("[deadline] task %s 终局规划出错: %v", t.ID, err)
} else if met { } else if met {
+63 -87
View File
@@ -10,7 +10,6 @@ import (
"github.com/Autumn-27/artex/agent" "github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/db" "github.com/Autumn-27/artex/db"
"github.com/Autumn-27/norma/llm"
) )
type goalSpec struct { type goalSpec struct {
@@ -27,25 +26,36 @@ type goalSpec struct {
// //
// 异步(goroutine)所以调用方立即返回,两条路径行为一致:秒建任务、后台拆目标。 // 异步(goroutine)所以调用方立即返回,两条路径行为一致:秒建任务、后台拆目标。
func (s *Server) launchTask(t *Task, seedText string, seedFirstIntent bool) { func (s *Server) launchTask(t *Task, seedText string, seedFirstIntent bool) {
if !s.engine.beginTaskOperation(t.ID) {
return
}
s.seed(t, seedText) s.seed(t, seedText)
if seedFirstIntent { if seedFirstIntent {
s.seedFirstIntent(t) s.seedFirstIntent(t)
} }
// 并发上限:满了就挂起排队(不做目标拆解、不启动引擎),由 reconcileConcurrency 补位启动。
if s.queueIfAtCapacity(t) { if s.queueIfAtCapacity(t) {
s.engine.decInflight(t.ID)
return 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) { 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", s.engine.emitActivity(t, db.Activity{Worker: "planner", Kind: "round",
Summary: "第 0 轮目标拆解"}) 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) s.engine.emitActivity(t, r)
}) })
if ctx.Err() != nil || s.engine.IsDeleting(t.ID) {
return
}
for _, g := range goals { for _, g := range goals {
summary := g.Text summary := g.Text
if g.VulnClass != "" { 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}) 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) s.engine.Run(s.ctx, t)
} }
// occupiesSlot reports whether a task counts against the concurrency cap: it is func (s *Server) occupiesConcurrencySlot(t *Task) bool {
// live (not terminal), not queued, and not paused. A just-created / decomposing return t != nil && !t.Queued && !t.Paused && !s.engine.IsPaused(t.ID) && !isTerminalStatus(t.Status)
// 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)
} }
// 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 { func (s *Server) runningTaskCount(excludeID string) int {
n := 0 count := 0
for _, t := range s.m.List() { for _, task := range s.m.List() {
if t.ID == excludeID { if task.ID != excludeID && s.occupiesConcurrencySlot(task) {
continue count++
}
if s.occupiesSlot(t) {
n++
} }
} }
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 { func (s *Server) queueIfAtCapacity(t *Task) bool {
s.concMu.Lock() s.concMu.Lock()
defer s.concMu.Unlock() 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 { if err := s.m.SetTaskQueued(t.ID, true); err != nil {
log.Printf("[concurrency] task %s 置排队失败: %v", t.ID, err) log.Printf("[concurrency] task %s 置排队失败: %v", t.ID, err)
return false // 落库失败宁可放它跑,也别静默丢任务 return false
} }
t.Queued = true s.engine.emitActivity(t, db.Activity{Worker: "system", Kind: "text",
s.engine.emitActivity(t, db.Activity{Worker: "planner", Kind: "text",
Summary: fmt.Sprintf("已排队:达到并发上限 %d,等待空位后自动开始", limit)}) Summary: fmt.Sprintf("已排队:达到并发上限 %d,等待空位后自动开始", limit)})
log.Printf("[concurrency] task %s 排队(并发上限 %d)", t.ID, limit)
return true 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() { func (s *Server) reconcileConcurrency() {
s.concMu.Lock() s.concMu.Lock()
defer s.concMu.Unlock() defer s.concMu.Unlock()
var queued []*Task queued := []*Task{}
for _, t := range s.m.List() { for _, task := range s.m.List() {
if t.Queued && !isTerminalStatus(t.Status) { if task.Queued && !isTerminalStatus(task.Status) {
queued = append(queued, t) queued = append(queued, task)
} }
} }
if len(queued) == 0 {
return
}
// 最早排队的先启动。
sort.Slice(queued, func(i, j int) bool { return queued[i].CreatedAt < queued[j].CreatedAt }) sort.Slice(queued, func(i, j int) bool { return queued[i].CreatedAt < queued[j].CreatedAt })
enabled, limit := s.m.ConcurrencyLimit() enabled, limit := s.m.ConcurrencyLimit()
slots := len(queued) // feature off → 全部放行 slots := len(queued)
if enabled { if enabled {
if !s.engine.Ready() {
return // 引擎未就绪(无 LLM):继续挂起,别空跑
}
slots = limit - s.runningTaskCount("") slots = limit - s.runningTaskCount("")
} }
for _, t := range queued { for _, task := range queued {
if slots <= 0 { if slots <= 0 {
break break
} }
if err := s.m.SetTaskQueued(t.ID, false); err != nil { // Keep FIFO order, but do not consume a concurrency slot for a task
log.Printf("[concurrency] task %s 解除排队失败: %v", t.ID, err) // 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 continue
} }
t.Queued = false if !s.engine.beginTaskOperation(task.ID) {
log.Printf("[concurrency] task %s 出队启动", t.ID) continue
go s.startTaskEngine(t) }
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-- slots--
} }
} }
@@ -153,7 +149,6 @@ func (s *Server) reviveTask(t *Task) {
if t == nil { if t == nil {
return return
} }
// 排队中的任务不复活启动——它本就等待并发空位,交给 reconcileConcurrency。
if t.Queued { if t.Queued {
return return
} }
@@ -184,24 +179,25 @@ func (s *Server) reviveTask(t *Task) {
// Returns the seeded specs so callers can emit activity records for them. // 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. // 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 { 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), // 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, // 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 // so FromEnv returned empty and every task silently fell back to the crude rule
// splitter (which shredded URLs / made meaningless 2-way splits). // splitter (which shredded URLs / made meaningless 2-way splits).
var specs []goalSpec var specs []goalSpec
if prov, ok := s.goalsProvider(t); ok { var as *db.AssetStore
var as *db.AssetStore if s.m != nil {
if s.m != nil { as = s.m.Assets()
as = s.m.Assets() }
} taskID, _ := strconv.ParseInt(t.ID, 10, 64)
taskID, _ := strconv.ParseInt(t.ID, 10, 64) s.engine.BeginLLMCall(t.ID)
// DecomposeGoals persists each decomposed goal via the set_goals tool straight decomposed := agent.DecomposeGoalsWithProvider(ctx, s.agentsForTask(t).runtime, s.m.dir, t.Goal, t.Description, as, t.Store, taskID, emit)
// into t.Store; it returns the specs it wrote so we can emit activity + spot the s.engine.EndLLMCall(t.ID)
// empty (LLM error / no provider) case below. No write-back needed here anymore. for _, g := range decomposed {
for _, g := range agent.DecomposeGoals(ctx, prov, s.m.dir, t.Goal, t.Description, as, t.Store, taskID, emit) { if strings.TrimSpace(g.Text) != "" {
if strings.TrimSpace(g.Text) != "" { specs = append(specs, goalSpec{Text: g.Text, VulnClass: g.VulnClass})
specs = append(specs, goalSpec{Text: g.Text, VulnClass: g.VulnClass})
}
} }
} }
if len(specs) == 0 { if len(specs) == 0 {
@@ -220,23 +216,3 @@ func (s *Server) createGoals(ctx context.Context, t *Task, emit func(db.Activity
} }
return specs 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
}
+12
View File
@@ -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)
}
}
+100
View File
@@ -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
}
+117
View File
@@ -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")
}
}
+365 -29
View File
@@ -2,6 +2,7 @@ package server
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log" "log"
"os" "os"
@@ -12,38 +13,44 @@ import (
"sync" "sync"
"time" "time"
actool "github.com/Autumn-27/norma/tool"
"github.com/Autumn-27/artex/agent" "github.com/Autumn-27/artex/agent"
pgdb "github.com/Autumn-27/artex/db" pgdb "github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/enrich" "github.com/Autumn-27/artex/enrich"
"github.com/Autumn-27/artex/guard" "github.com/Autumn-27/artex/guard"
"github.com/Autumn-27/artex/intercept" "github.com/Autumn-27/artex/intercept"
"github.com/Autumn-27/artex/traffic" "github.com/Autumn-27/artex/traffic"
actool "github.com/Autumn-27/norma/tool"
) )
// Task is one engagement: a description + goal + its own exploration store, // 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 // sharing the process-wide asset store. ID is the PG task id as a string; ExpID
// is the exploration the task owns. // is the exploration the task owns.
type Task struct { type Task struct {
ID string `json:"id"` ID string `json:"id"`
ExpID int64 `json:"exploration_id"` ExpID int64 `json:"exploration_id"`
Description string `json:"description"` Description string `json:"description"`
Goal string `json:"goal"` Goal string `json:"goal"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
CompletedAt int64 `json:"completed_at,omitempty"` // 进入终态的 unix 秒;0=未完成 CompletedAt int64 `json:"completed_at,omitempty"` // 进入终态的 unix 秒;0=未完成
Paused bool `json:"paused"` Paused bool `json:"paused"`
Queued bool `json:"queued"` // 因并发上限被挂起、等待空位自动启动;true=尚未开跑 Queued bool `json:"queued"` // 因并发上限被挂起、等待空位自动启动;true=尚未开跑
ParentRef string `json:"parent_ref,omitempty"` // 父任务 id(编排 spawn 记录) ParentRef string `json:"parent_ref,omitempty"` // 父任务 id(编排 spawn 记录)
LLMProfileID *int64 `json:"llm_profile_id,omitempty"` // 指定运行本任务 planner/worker 的 LLM 配置;nil=用全局激活配置 LLMProfileID *int64 `json:"llm_profile_id,omitempty"` // 指定运行本任务 planner/worker 的 LLM 配置;nil=用全局激活配置
Status string `json:"status"` // persisted lifecycle status (done/failed/timeout 为终态;空/其它则由运行态推导) 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=未设/未运行。 // 任务级超时(见 docs/任务级超时与收尾设计.md)。DeadlineAt/FirstRunAt 为 unix 秒,0=未设/未运行。
TimeoutSeconds int `json:"timeout_seconds"` TimeoutSeconds int `json:"timeout_seconds"`
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒) PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒)
FirstRunAt int64 `json:"first_run_at,omitempty"` FirstRunAt int64 `json:"first_run_at,omitempty"`
DeadlineAt int64 `json:"deadline_at,omitempty"` DeadlineAt int64 `json:"deadline_at,omitempty"`
Store *pgdb.ExplorationStore `json:"-"` Store *pgdb.ExplorationStore `json:"-"`
Guard *guard.Guard `json:"-"` Guard *guard.Guard `json:"-"`
notify chan struct{} notify chan struct{}
llmMu sync.RWMutex
// pendingTriggers accumulates the concrete changes (worker done / finding) that // pendingTriggers accumulates the concrete changes (worker done / finding) that
// fired planning rounds since the last one consumed them. The debounce coalesces // fired planning rounds since the last one consumed them. The debounce coalesces
@@ -52,14 +59,74 @@ type Task struct {
pendingTriggers []agent.TriggerEvent 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 &copy
}
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 // Manager owns the PostgreSQL data source (asset graph + every task's exploration
// graph + config) and the in-memory set of task handles. // graph + config) and the in-memory set of task handles.
type Manager struct { type Manager struct {
dir string dir string
pg *pgdb.DB pg *pgdb.DB
assets *pgdb.AssetStore assets *pgdb.AssetStore
traffic *traffic.Traffic // process-wide recording proxy (may be nil) traffic *traffic.Traffic // process-wide recording proxy (may be nil)
enrich *enrich.Engine // engine-side asset auto-completion (DNS/HTTP) enrich *enrich.Engine // engine-side asset auto-completion (DNS/HTTP)
interceptor *intercept.Interceptor // user-configured tool-call interception rules interceptor *intercept.Interceptor // user-configured tool-call interception rules
mu sync.RWMutex 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, 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, 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, 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, TimeoutSeconds: pt.TimeoutSeconds, PlanHeartbeatSeconds: pt.PlanHeartbeatSeconds,
FirstRunAt: unixOrZero(pt.FirstRunAt), DeadlineAt: unixOrZero(pt.DeadlineAt), FirstRunAt: unixOrZero(pt.FirstRunAt), DeadlineAt: unixOrZero(pt.DeadlineAt),
Store: store, Guard: guard.NewWithInterceptor(ic), notify: make(chan struct{}, 1), 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. // CreateTask creates a task + its exploration and makes it active.
// timeoutSeconds is the task-level wall-clock budget (0 = 不限时). // timeoutSeconds is the task-level wall-clock budget (0 = 不限时).
func (m *Manager) CreateTask(description, goal string, llmProfileID *int64, timeoutSeconds, planHeartbeatSeconds int) (*Task, error) { 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 { if err != nil {
return nil, err return nil, err
} }
@@ -551,6 +631,35 @@ func (m *Manager) CreateTask(description, goal string, llmProfileID *int64, time
return t, nil 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. // LoadExisting rebuilds in-memory task handles from the PG task registry.
func (m *Manager) LoadExisting() []*Task { func (m *Manager) LoadExisting() []*Task {
pts, err := m.pg.ListTasks() pts, err := m.pg.ListTasks()
@@ -706,17 +815,123 @@ func (m *Manager) SetTaskStatus(id, status string) error {
return nil return nil
} }
// DeleteTask removes a task (and its exploration subgraph) from PG + memory. // DeleteTask removes a task and optionally its related global data. Traffic has
func (m *Manager) DeleteTask(id string) error { // 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) n, err := strconv.ParseInt(id, 10, 64)
if err != nil { if err != nil {
return err return result, err
} }
if err := m.pg.DeleteTask(n); err != nil { registered, err := m.pg.GetTask(n)
return err 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() m.mu.Lock()
delete(m.tasks, id) 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 { if m.active == id {
m.active = "" m.active = ""
for _, t := range m.tasks { for _, t := range m.tasks {
@@ -725,7 +940,128 @@ func (m *Manager) DeleteTask(id string) error {
} }
} }
m.mu.Unlock() 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) { func (m *Manager) Task(id string) (*Task, bool) {
+280
View File
@@ -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)
}
}
+6 -4
View File
@@ -199,12 +199,13 @@ func (s *Server) toolListTasks() actool.CoreTool {
if t.ParentRef != "" { if t.ParentRef != "" {
row["parent_ref"] = t.ParentRef row["parent_ref"] = t.ParentRef
} }
if t.LLMProfileID == nil { llmState := t.llmStateSnapshot()
if llmState.ProfileID == nil {
row["llm_profile"] = "(激活配置)" row["llm_profile"] = "(激活配置)"
} else if n, ok := profName[*t.LLMProfileID]; ok { } else if n, ok := profName[*llmState.ProfileID]; ok {
row["llm_profile"] = n row["llm_profile"] = n
} else { } else {
row["llm_profile"] = fmt.Sprintf("#%d(已删除)", *t.LLMProfileID) row["llm_profile"] = fmt.Sprintf("#%d(已删除)", *llmState.ProfileID)
} }
out = append(out, row) out = append(out, row)
} }
@@ -305,6 +306,7 @@ func (s *Server) toolPauseTask() actool.CoreTool {
return actool.Errorf("task 不存在: " + a.TaskID), nil return actool.Errorf("task 不存在: " + a.TaskID), nil
} }
s.engine.Pause(a.TaskID, agent.AbortPausedByOrchestrator) s.engine.Pause(a.TaskID, agent.AbortPausedByOrchestrator)
s.cancelTaskChat(a.TaskID, agent.AbortChatPausedWithTask)
return actool.Text("task paused: " + a.TaskID), nil return actool.Text("task paused: " + a.TaskID), nil
}) })
} }
@@ -419,7 +421,7 @@ func (s *Server) deriveTaskStatus(t *Task) string {
return t.Status return t.Status
case t.Paused || s.engine.IsPaused(t.ID): case t.Paused || s.engine.IsPaused(t.ID):
return "paused" return "paused"
case s.engine.Ready() && s.engine.Started(t.ID): case s.engine.ReadyFor(t) && s.engine.Started(t.ID):
return "running" return "running"
} }
return "created" return "created"
+658 -116
View File
File diff suppressed because it is too large. Load diff
+129 -53
View File
@@ -5,6 +5,7 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"log" "log"
@@ -66,61 +67,97 @@ func decode(r *http.Request, v any) error { return json.NewDecoder(r.Body).Decod
// ---------- tasks (delete) ---------- // ---------- tasks (delete) ----------
// pgDeleteTask removes a task and, per opt-in flags in the (optional) JSON body, const taskDeleteDrainTimeout = 10 * time.Second
// 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 → func canonicalTaskID(raw string) (string, bool) {
// only the task + its exploration subgraph are removed (legacy behavior). 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) { func (s *Server) pgDeleteTask(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") id, ok := canonicalTaskID(r.PathValue("id"))
var opts struct { if !ok {
Assets bool `json:"assets"` writeErr(w, http.StatusBadRequest, "任务 id 无效")
Findings bool `json:"findings"` return
Traffic bool `json:"traffic"`
Files bool `json:"files"`
} }
_ = json.NewDecoder(r.Body).Decode(&opts) // optional body; ignore EOF/parse errors var opts DeleteTaskOptions
if err := decode(r, &opts); err != nil && err != io.EOF {
result := map[string]any{"deleted": id} writeErr(w, 400, "invalid JSON: "+err.Error())
nid, _ := strconv.ParseInt(id, 10, 64) return
as := s.m.Assets() }
if !s.engine.BeginDelete(id) {
// Traffic first: it needs the task's hosts, which come from assets that a later writeErr(w, http.StatusConflict, "任务正在删除")
// asset-delete would remove. Traffic is host-keyed (no per-task tag), so this return
// purges ALL recorded traffic for those hosts, not just this task's. }
if opts.Traffic && nid > 0 && as != nil { deleted := false
if tr := s.m.Traffic(); tr != nil { defer func() {
if hosts, err := as.HostsByTask(nid); err == nil && len(hosts) > 0 { if !deleted {
if n, err := tr.DeleteHostsExact(hosts); err == nil { s.engine.AbortDelete(id)
result["traffic_deleted"] = n
}
}
} }
} }()
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 // Main-agent runs use a separate context from planner/workers. Cancel it, then
if err := s.m.DeleteTask(id); err != nil { // wait until both execution domains have returned before removing transcripts.
writeErr(w, 500, err.Error()) 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 return
} }
// Files last: the per-task working dir <workDir>/tasks/<id> (worker artifacts + result, err := s.m.DeleteTask(id, opts)
// uploads). Confined to the work dir; id is numeric so no traversal risk. if err != nil {
if opts.Files && nid > 0 { var committed *taskDeleteCommittedError
if err := os.RemoveAll(filepath.Join(s.m.dir, "tasks", id)); err == nil { if errors.As(err, &committed) {
result["files_deleted"] = true // 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 ---------- // ---------- agents ----------
@@ -352,6 +389,9 @@ func (s *Server) pgSaveAgentConfig(w http.ResponseWriter, r *http.Request) {
if profileChanged { if profileChanged {
s.invalidateProfileAgents() 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. // rebuild the live agents so the new max_turns/run_seconds/binding apply without a restart.
s.cfgMu.Lock() s.cfgMu.Lock()
cfg, on := s.llmCfg, s.llmOn 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{ writeJSON(w, 200, map[string]any{
"agent": agentDTO(a), "prompt": cur, "variables": vars, "versions": vers, "agent": agentDTO(a), "prompt": cur, "variables": vars, "versions": vers,
"visibility": map[string]any{"mcp": mcp, "skill": sk}, "visibility": map[string]any{"mcp": mcp, "skill": sk},
"llm_profiles": llmProfiles, // 可绑定的 LLM 配置候选 "llm_profiles": llmProfiles, // 可绑定的 LLM 配置候选
"wrapup_prompt": a.WrapupPrompt, // 已保存的收尾提示词(空=用内置默认) "wrapup_prompt": a.WrapupPrompt, // 已保存的收尾提示词(空=用内置默认)
"wrapup_default": agent.WrapupDefault(a.Key), // 内置默认(供占位/恢复默认) "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()) writeErr(w, 500, err.Error())
return 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() s.invalidateProfileAgents()
// Hot-apply. Not just when the ACTIVE profile changed: with failover on, any // 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 // 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 return
} }
id, _ := pathInt(r, "id") id, _ := pathInt(r, "id")
if err := pg.DeleteProfile(id); err != nil { if err := pg.DeleteProfileContext(r.Context(), id); err != nil {
writeErr(w, 500, err.Error()) 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 return
} }
s.invalidateProfileAgents() // drop cached agents for the removed profile 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.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.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}) 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) { func (s *Server) pgActivateProfile(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w) pg := s.pg(w)
if pg == nil { if pg == nil {
+220
View File
@@ -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)
}
}
+462
View File
@@ -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})
}
+507
View File
@@ -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)
}
}
+196 -18
View File
@@ -10,6 +10,7 @@ import (
"database/sql" "database/sql"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io/fs" "io/fs"
"log" "log"
@@ -24,9 +25,9 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/norma/permission" "github.com/Autumn-27/norma/permission"
actool "github.com/Autumn-27/norma/tool" actool "github.com/Autumn-27/norma/tool"
"github.com/Autumn-27/artex/db"
mproxy "github.com/lqqyt2423/go-mitmproxy/proxy" mproxy "github.com/lqqyt2423/go-mitmproxy/proxy"
_ "modernc.org/sqlite" _ "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 // so picking "api.example.com" never sweeps "api.example.com.cn". Returns rows
// deleted. Duplicate hosts are harmless (idempotent deletes, single tree pass). // deleted. Duplicate hosts are harmless (idempotent deletes, single tree pass).
func (t *Traffic) DeleteHostsExact(hosts []string) (int64, error) { func (t *Traffic) DeleteHostsExact(hosts []string) (int64, error) {
t.wmu.Lock() stage, err := t.StageDeleteHostsExact(hosts)
defer t.wmu.Unlock() if err != nil {
var deleted int64 return 0, err
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
}
} }
if deleted > 0 { deleted := stage.Deleted()
_ = t.gcBlobs() if err := stage.Commit(); err != nil {
return deleted, err
} }
return deleted, nil 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 // gcBlobs removes content-addressed blobs in _blobs that no remaining exchange
// tree references. References appear as "@blob sha256:<hex>" lines inside the // tree references. References appear as "@blob sha256:<hex>" lines inside the
// tree's request.http/response.http files (see bodyOrBlob). Best-effort: walk // tree's request.http/response.http files (see bodyOrBlob). Best-effort: walk
+146
View File
@@ -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 // TestDeleteHostGCBlobs verifies blob garbage collection: after a host's trees
// are removed, blobs referenced by no remaining exchange are deleted, while // are removed, blobs referenced by no remaining exchange are deleted, while
// blobs still referenced (including shared ones) survive. // blobs still referenced (including shared ones) survive.
@@ -6,9 +6,9 @@ import { usePathname } from "next/navigation";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { SidebarTrigger } from "@/components/ui/sidebar"; import { SidebarTrigger } from "@/components/ui/sidebar";
import { useCurrentUser } from "@/hooks/use-current-user";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useCurrentUser } from "@/hooks/use-current-user";
import { AccountSwitcher } from "./sidebar/account-switcher"; import { AccountSwitcher } from "./sidebar/account-switcher";
import { LayoutControls } from "./sidebar/layout-controls"; import { LayoutControls } from "./sidebar/layout-controls";
@@ -62,7 +62,7 @@ export function MainContent({ children }: { children: ReactNode }) {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{version && ( {version && (
<span className="text-muted-foreground text-xs font-medium tabular-nums">版本 · {version}</span> <span className="font-medium text-muted-foreground text-xs tabular-nums">版本 · {version}</span>
)} )}
<LayoutControls /> <LayoutControls />
<ThemeSwitcher /> <ThemeSwitcher />
@@ -2,29 +2,20 @@
import * as React from "react"; import * as React from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { toast } from "sonner"; 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 { SidebarTrigger } from "@/components/ui/sidebar";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { import { Badge } from "@/components/ui/badge";
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectGroup,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
} from "@/components/ui/select"; } from "@/components/ui/select";
@@ -72,9 +63,9 @@ function FieldRow({
} }
function FindingDetailInner() { function FindingDetailInner() {
const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const id = searchParams.get("id") ?? ""; const id = searchParams.get("id") ?? "";
const contextTaskId = searchParams.get("context_task") ?? "";
const [finding, setFinding] = React.useState<Finding | null>(null); const [finding, setFinding] = React.useState<Finding | null>(null);
const [loaded, setLoaded] = React.useState(false); const [loaded, setLoaded] = React.useState(false);
const [tab, setTab] = React.useState("overview"); const [tab, setTab] = React.useState("overview");
@@ -85,18 +76,18 @@ function FindingDetailInner() {
return; return;
} }
api api
.getFinding(id) .getFinding(id, contextTaskId || undefined)
.then((f) => setFinding(f)) .then((f) => setFinding(f))
.catch(() => setFinding(null)) .catch(() => setFinding(null))
.finally(() => setLoaded(true)); .finally(() => setLoaded(true));
}, [id]); }, [contextTaskId, id]);
React.useEffect(() => { React.useEffect(() => {
load(); load();
}, [load]); }, [load]);
const changeSeverity = React.useCallback( const changeSeverity = React.useCallback(
async (next: Severity) => { async (next: Severity) => {
if (!finding || next === finding.severity) return; if (!finding || finding.inherited || next === finding.severity) return;
const prev = finding.severity; const prev = finding.severity;
setFinding({ ...finding, severity: next }); setFinding({ ...finding, severity: next });
try { try {
@@ -113,7 +104,7 @@ function FindingDetailInner() {
const changeStatus = React.useCallback( const changeStatus = React.useCallback(
async (next: FindingStatus) => { async (next: FindingStatus) => {
if (!finding || next === finding.status) return; if (!finding || finding.inherited || next === finding.status) return;
const prev = finding.status; const prev = finding.status;
setFinding({ ...finding, status: next }); setFinding({ ...finding, status: next });
try { try {
@@ -128,18 +119,6 @@ function FindingDetailInner() {
[finding, id], [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) { if (!finding) {
return ( return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-10 text-center"> <div className="flex flex-1 flex-col items-center justify-center gap-3 p-10 text-center">
@@ -184,25 +163,11 @@ function FindingDetailInner() {
<Separator orientation="vertical" className="mx-1 h-4" /> <Separator orientation="vertical" className="mx-1 h-4" />
<StatusBadge domain="severity" value={finding.severity} dot /> <StatusBadge domain="severity" value={finding.severity} dot />
<StatusBadge domain="finding" value={finding.status} dot /> <StatusBadge domain="finding" value={finding.status} dot />
<AlertDialog> {finding.inherited && finding.source_task_id && (
<AlertDialogTrigger asChild> <Badge variant="outline">
<Button variant="outline" size="sm" className="ml-auto text-destructive"> 来源任务 #{finding.source_task_id} · 只读
<Trash2Icon /> 删除 </Badge>
</Button> )}
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除该漏洞?</AlertDialogTitle>
<AlertDialogDescription className="break-words">
「<span className="break-all">{title}</span>」将被永久删除,同时从发现列表、任务发现 Tab 与探索图中移除,此操作不可撤销。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={deleteFinding}>删除</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
<TabsList> <TabsList>
<TabsTrigger value="overview">概览</TabsTrigger> <TabsTrigger value="overview">概览</TabsTrigger>
@@ -271,48 +236,60 @@ function FindingDetailInner() {
</code> </code>
</FieldRow> </FieldRow>
{/* 严重等级(可改) */} {/* 严重等级 */}
<FieldRow label="严重等级"> <FieldRow label="严重等级">
<Select {finding.inherited ? (
value={finding.severity} <StatusBadge domain="severity" value={finding.severity} dot />
onValueChange={(v) => changeSeverity(v as Severity)} ) : (
> <Select
<SelectTrigger value={finding.severity}
size="sm" onValueChange={(v) => changeSeverity(v as Severity)}
className="h-7 w-auto border-none px-1 shadow-none focus-visible:ring-0"
> >
<StatusBadge domain="severity" value={finding.severity} dot /> <SelectTrigger
</SelectTrigger> size="sm"
<SelectContent position="popper" align="end"> className="h-7 w-auto border-none px-1 shadow-none focus-visible:ring-0"
{SEVERITIES.map((sv) => ( >
<SelectItem key={sv} value={sv}> <StatusBadge domain="severity" value={finding.severity} dot />
{statusMeta("severity", sv).label} </SelectTrigger>
</SelectItem> <SelectContent position="popper" align="end">
))} <SelectGroup>
</SelectContent> {SEVERITIES.map((sv) => (
</Select> <SelectItem key={sv} value={sv}>
{statusMeta("severity", sv).label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)}
</FieldRow> </FieldRow>
{/* 处理状态(可改) */} {/* 处理状态 */}
<FieldRow label="处理状态"> <FieldRow label="处理状态">
<Select {finding.inherited ? (
value={finding.status} <StatusBadge domain="finding" value={finding.status} dot />
onValueChange={(v) => changeStatus(v as FindingStatus)} ) : (
> <Select
<SelectTrigger value={finding.status}
size="sm" onValueChange={(v) => changeStatus(v as FindingStatus)}
className="h-7 w-auto border-none px-1 shadow-none focus-visible:ring-0"
> >
<StatusBadge domain="finding" value={finding.status} dot /> <SelectTrigger
</SelectTrigger> size="sm"
<SelectContent position="popper" align="end"> className="h-7 w-auto border-none px-1 shadow-none focus-visible:ring-0"
{FINDING_STATUSES.map((st) => ( >
<SelectItem key={st} value={st}> <StatusBadge domain="finding" value={finding.status} dot />
{statusMeta("finding", st).label} </SelectTrigger>
</SelectItem> <SelectContent position="popper" align="end">
))} <SelectGroup>
</SelectContent> {FINDING_STATUSES.map((st) => (
</Select> <SelectItem key={st} value={st}>
{statusMeta("finding", st).label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)}
</FieldRow> </FieldRow>
{/* 漏洞类型 */} {/* 漏洞类型 */}
@@ -152,7 +152,6 @@ const TABS: { key: string; label: string; icon: LucideIcon }[] = [
]; ];
export function AssetsTab({ taskId }: { taskId: string }) { export function AssetsTab({ taskId }: { taskId: string }) {
// 服务端分页:只取当前分类的当前页,分类总数走 /assets/counts?task_id=,不做数量截断
const [rows, setRows] = React.useState<Asset[]>([]); const [rows, setRows] = React.useState<Asset[]>([]);
const [total, setTotal] = React.useState(0); const [total, setTotal] = React.useState(0);
const [counts, setCounts] = React.useState<Record<string, number>>({}); const [counts, setCounts] = React.useState<Record<string, number>>({});
@@ -161,7 +160,6 @@ export function AssetsTab({ taskId }: { taskId: string }) {
const [page, setPage] = React.useState(0); const [page, setPage] = React.useState(0);
const [size, setSize] = React.useState(50); const [size, setSize] = React.useState(50);
// 换分类/换页大小:回到第一页,并清掉旧行(各分类列不同,不能串用)
React.useEffect(() => { React.useEffect(() => {
setPage(0); setPage(0);
setRows([]); setRows([]);
@@ -171,17 +169,16 @@ export function AssetsTab({ taskId }: { taskId: string }) {
let alive = true; let alive = true;
const load = async () => { const load = async () => {
try { try {
const [cur, c] = await Promise.all([ const [cur, nextCounts] = await Promise.all([
api.taskAssets(taskId, tab, size, page * size), api.taskAssets(taskId, tab, size, page * size),
api.assetCounts(taskId), api.assetCounts(taskId),
]); ]);
if (!alive) return; if (!alive) return;
setRows(cur.assets); setRows(cur.assets);
setTotal(cur.total); setTotal(cur.total);
setCounts(c ?? {}); setCounts(nextCounts ?? {});
setLoaded(true);
} catch { } catch {
// 保留上一次的数据
} finally {
if (alive) setLoaded(true); if (alive) setLoaded(true);
} }
}; };
@@ -190,7 +187,6 @@ export function AssetsTab({ taskId }: { taskId: string }) {
return () => { alive = false; clearInterval(timer); }; return () => { alive = false; clearInterval(timer); };
}, [taskId, tab, page, size]); }, [taskId, tab, page, size]);
// 轮询期间资产变少时,别停在越界的空页上
React.useEffect(() => { React.useEffect(() => {
const maxPage = Math.max(0, Math.ceil(total / size) - 1); const maxPage = Math.max(0, Math.ceil(total / size) - 1);
if (page > maxPage) setPage(maxPage); if (page > maxPage) setPage(maxPage);
@@ -14,9 +14,9 @@ import {
Waypoints, Waypoints,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { import {
Sheet, Sheet,
@@ -26,6 +26,7 @@ import {
} from "@/components/ui/sheet"; } from "@/components/ui/sheet";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import type { CoverageAssetRef, CoverageAssetRefs, CoverageGraphEdge, CoverageGraphNode } from "@/lib/types"; import type { CoverageAssetRef, CoverageAssetRefs, CoverageGraphEdge, CoverageGraphNode } from "@/lib/types";
import { cn } from "@/lib/utils";
// 每个父节点下、同一类型的子节点默认展示的数量;超出折叠,"展示更多"每次再拉这么多。 // 每个父节点下、同一类型的子节点默认展示的数量;超出折叠,"展示更多"每次再拉这么多。
const FOLD_LIMIT = 20; const FOLD_LIMIT = 20;
@@ -274,10 +275,18 @@ function RefList({ title, items }: { title: string; items: CoverageAssetRef[] })
</h4> </h4>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{items.map((r) => ( {items.map((r) => (
<div key={`${r.kind}-${r.id}`} className="bg-muted/50 flex items-start gap-2 rounded-md px-2 py-1.5 text-xs"> <div
<span className="text-muted-foreground shrink-0 font-mono">#{r.id}</span> key={`${r.kind}-${r.id}`}
{r.state && <span className="text-muted-foreground shrink-0">{r.state}</span>} className="flex flex-wrap items-start gap-2 rounded-md bg-muted/50 px-2 py-1.5 text-xs"
<span className="text-foreground min-w-0 flex-1 break-words">{r.summary || "—"}</span> >
<span className="shrink-0 font-mono text-muted-foreground">#{r.id}</span>
{r.state && <span className="shrink-0 text-muted-foreground">{r.state}</span>}
<span className="min-w-32 flex-1 break-words text-foreground">{r.summary || "—"}</span>
{r.inherited && r.source_task_id && (
<Badge variant="outline" className="shrink-0">
来源 #{r.source_task_id} · 只读
</Badge>
)}
</div> </div>
))} ))}
</div> </div>
@@ -2,25 +2,15 @@
import * as React from "react"; import * as React from "react";
import Link from "next/link"; import Link from "next/link";
import { ArrowUpRightIcon, ChevronRightIcon, Trash2Icon } from "lucide-react"; import { ArrowUpRightIcon, ChevronRightIcon } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { StatusBadge } from "@/components/status-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 { import {
Select, Select,
SelectContent, SelectContent,
SelectGroup,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
} from "@/components/ui/select"; } from "@/components/ui/select";
@@ -42,12 +32,12 @@ const FINDING_STATUSES: FindingStatus[] = [
function Row({ function Row({
f, f,
contextTaskId,
onStatus, onStatus,
onDelete,
}: { }: {
f: Finding; f: Finding;
contextTaskId: string;
onStatus: (f: Finding, next: FindingStatus) => void; onStatus: (f: Finding, next: FindingStatus) => void;
onDelete: (f: Finding) => void;
}) { }) {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
return ( return (
@@ -65,10 +55,17 @@ function Row({
)} )}
/> />
<StatusBadge domain="severity" value={f.severity} dot /> <StatusBadge domain="severity" value={f.severity} dot />
<div className="flex min-w-0 flex-col"> <div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="truncate font-medium"> <div className="flex min-w-0 items-center gap-2">
{f.name || f.vulnclass || "未分类"} <span className="truncate font-medium">
</span> {f.name || f.vulnclass || "未分类"}
</span>
{f.inherited && f.source_task_id && (
<Badge variant="outline" className="shrink-0">
来源 #{f.source_task_id} · 只读
</Badge>
)}
</div>
<span className="truncate text-xs text-muted-foreground"> <span className="truncate text-xs text-muted-foreground">
{f.summary} {f.summary}
</span> </span>
@@ -92,7 +89,7 @@ function Row({
)} )}
</div> </div>
)} )}
{f.finding_id ? ( {f.finding_id && !f.inherited ? (
<Select <Select
value={f.status} value={f.status}
onValueChange={(v) => onStatus(f, v as FindingStatus)} onValueChange={(v) => onStatus(f, v as FindingStatus)}
@@ -104,11 +101,13 @@ function Row({
<StatusBadge domain="finding" value={f.status} dot /> <StatusBadge domain="finding" value={f.status} dot />
</SelectTrigger> </SelectTrigger>
<SelectContent position="popper" align="end"> <SelectContent position="popper" align="end">
{FINDING_STATUSES.map((st) => ( <SelectGroup>
<SelectItem key={st} value={st}> {FINDING_STATUSES.map((st) => (
{statusMeta("finding", st).label} <SelectItem key={st} value={st}>
</SelectItem> {statusMeta("finding", st).label}
))} </SelectItem>
))}
</SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
) : ( ) : (
@@ -119,7 +118,11 @@ function Row({
</span> </span>
{f.finding_id && ( {f.finding_id && (
<Link <Link
href={`/function/findings/detail?id=${f.finding_id}`} href={
f.inherited
? `/function/findings/detail?id=${f.finding_id}&context_task=${contextTaskId}`
: `/function/findings/detail?id=${f.finding_id}`
}
className="text-muted-foreground hover:text-primary inline-flex shrink-0 items-center gap-0.5 text-xs" className="text-muted-foreground hover:text-primary inline-flex shrink-0 items-center gap-0.5 text-xs"
title="查看漏洞详情" title="查看漏洞详情"
> >
@@ -130,30 +133,8 @@ function Row({
</div> </div>
{open && ( {open && (
<div className="bg-muted/30 px-4 pb-4 pl-11"> <div className="bg-muted/30 px-4 pb-4 pl-11">
<div className="mb-1 flex items-center justify-between gap-2"> <div className="mb-1 text-xs font-medium text-muted-foreground">
<span className="text-xs font-medium text-muted-foreground">证据 / PoC</span> 证据 / PoC
{f.finding_id && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="outline" className="h-7 text-destructive">
<Trash2Icon /> 删除
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除该漏洞?</AlertDialogTitle>
<AlertDialogDescription className="break-words">
「<span className="break-all">{f.name || f.vulnclass || f.summary || `#${f.finding_id}`}</span>」将被永久删除,
同时从发现列表、任务发现 Tab 与探索图中移除,此操作不可撤销。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={() => onDelete(f)}>删除</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div> </div>
<pre className="overflow-auto rounded-md border bg-background p-3 font-mono text-xs whitespace-pre-wrap"> <pre className="overflow-auto rounded-md border bg-background p-3 font-mono text-xs whitespace-pre-wrap">
{f.evidence} {f.evidence}
@@ -187,7 +168,7 @@ export function FindingsTab({ taskId }: { taskId: string }) {
const onStatus = React.useCallback( const onStatus = React.useCallback(
async (f: Finding, next: FindingStatus) => { 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; const prev = f.status;
setFindings((cur) => setFindings((cur) =>
cur.map((x) => (x.id === f.id ? { ...x, status: next } : x)), 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 const items = findings
.filter((f) => f.task_id === taskId) .filter((f) => f.task_id === taskId || f.inherited)
.sort((a, b) => { .sort((a, b) => {
const order = { critical: 0, high: 1, medium: 2, low: 3 }; const order = { critical: 0, high: 1, medium: 2, low: 3 };
return order[a.severity] - order[b.severity]; return order[a.severity] - order[b.severity];
@@ -227,11 +197,11 @@ export function FindingsTab({ taskId }: { taskId: string }) {
<Card className="overflow-hidden py-0"> <Card className="overflow-hidden py-0">
<CardContent className="px-0"> <CardContent className="px-0">
{items.map((f) => ( {items.map((f) => (
<Row key={f.id} f={f} onStatus={onStatus} onDelete={onDelete} /> <Row key={f.id} f={f} contextTaskId={taskId} onStatus={onStatus} />
))} ))}
{items.length === 0 && ( {items.length === 0 && (
<p className="px-4 py-8 text-center text-sm text-muted-foreground"> <p className="px-4 py-8 text-center text-sm text-muted-foreground">
本任务暂无确认发现。 本任务及直接关联任务暂无确认发现。
</p> </p>
)} )}
</CardContent> </CardContent>
@@ -1,8 +1,11 @@
"use client"; "use client";
import * as React from "react"; import * as React from "react";
import { FileTextIcon, CopyIcon, CheckIcon } from "lucide-react";
import { CheckIcon, CopyIcon, FileTextIcon } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Markdown } from "@/components/markdown";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
@@ -39,6 +42,29 @@ export function ReportTab({ taskId }: { taskId: string }) {
setTimeout(() => setCopied(false), 1500); setTimeout(() => setCopied(false), 1500);
} }
let content: React.ReactNode;
if (loading) {
content = (
<div className="flex flex-col items-center justify-center gap-2 rounded-md border border-dashed py-16 text-muted-foreground text-sm">
<FileTextIcon className="size-8 opacity-40" />
加载中…
</div>
);
} else if (report) {
content = (
<div className="max-h-[60vh] overflow-auto rounded-md border bg-muted/20 p-4">
<Markdown text={report} />
</div>
);
} else {
content = (
<div className="flex flex-col items-center justify-center gap-2 rounded-md border border-dashed py-16 text-muted-foreground text-sm">
<FileTextIcon className="size-8 opacity-40" />
暂无报告
</div>
);
}
return ( return (
<Card> <Card>
<CardHeader className="flex-row items-center justify-between"> <CardHeader className="flex-row items-center justify-between">
@@ -53,23 +79,7 @@ export function ReportTab({ taskId }: { taskId: string }) {
)} )}
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>{content}</CardContent>
{loading ? (
<div className="flex flex-col items-center justify-center gap-2 rounded-md border border-dashed py-16 text-sm text-muted-foreground">
<FileTextIcon className="size-8 opacity-40" />
加载中…
</div>
) : report ? (
<pre className="max-h-[60vh] overflow-auto rounded-md border bg-muted/40 p-4 font-mono text-xs whitespace-pre-wrap">
{report}
</pre>
) : (
<div className="flex flex-col items-center justify-center gap-2 rounded-md border border-dashed py-16 text-sm text-muted-foreground">
<FileTextIcon className="size-8 opacity-40" />
暂无报告
</div>
)}
</CardContent>
</Card> </Card>
); );
} }
@@ -1,30 +1,46 @@
"use client"; "use client";
import * as React from "react"; import * as React from "react";
import { import {
RadioIcon,
BrainIcon, BrainIcon,
UserIcon,
Loader2Icon,
ZapOffIcon,
SendIcon,
SquareIcon,
ClockIcon,
CircleCheckIcon, CircleCheckIcon,
CircleXIcon,
CircleSlashIcon, CircleSlashIcon,
ShieldAlertIcon, CircleXIcon,
WifiOffIcon, ClockIcon,
RotateCwIcon, HistoryIcon,
Loader2Icon,
PaperclipIcon, PaperclipIcon,
PauseIcon,
PlayIcon,
RadioIcon,
RotateCwIcon,
SendIcon,
ShieldAlertIcon,
SquareIcon,
UserIcon,
WifiOffIcon,
XIcon, XIcon,
ZapOffIcon,
Trash2Icon,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Transcript } from "@/components/transcript";
import { TodoPopover } from "@/components/todo-popover"; import { TodoPopover } from "@/components/todo-popover";
import { Transcript } from "@/components/transcript";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; 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 { ScrollArea } from "@/components/ui/scroll-area";
import { import {
Tooltip, Tooltip,
@@ -43,6 +59,7 @@ import type {
TaskNode, TaskNode,
TokenTotal, TokenTotal,
} from "@/lib/types"; } from "@/lib/types";
import { cn } from "@/lib/utils";
// fmtBytes renders a human file size for attachment chips (mirrors transcript.tsx). // fmtBytes renders a human file size for attachment chips (mirrors transcript.tsx).
function fmtBytes(n: number): string { 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. // so refresh / tab-switch / sleep / reconnect never drop the newest records.
const PAGE = 200; // history page size 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 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 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 // 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). // BOTH the Goal Agent's round-0 decomposition and the Planner (single Plan session).
function sessionKeyOf(a: Activity): string { 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 === "mainagent") return "main";
if (a.worker === "planner") return "plan"; if (a.worker === "planner") return "plan";
if (a.intent_id) return `intent:${a.intent_id}`; if (a.intent_id) return `intent:${a.intent_id}`;
@@ -122,6 +141,8 @@ function statusIcon(status: SessionStatus) {
switch (status) { switch (status) {
case "running": // 执行中 case "running": // 执行中
return <Loader2Icon className="size-3.5 animate-spin text-blue-500" />; return <Loader2Icon className="size-3.5 animate-spin text-blue-500" />;
case "paused":
return <PauseIcon className="size-3.5 text-amber-500" />;
case "pending": // 待领取(open intent) case "pending": // 待领取(open intent)
return <ClockIcon className="size-3.5 text-muted-foreground" />; return <ClockIcon className="size-3.5 text-muted-foreground" />;
case "done": // 完成 case "done": // 完成
@@ -156,6 +177,7 @@ const roleMeta = {
mainagent: { label: "主 Agent", icon: UserIcon }, mainagent: { label: "主 Agent", icon: UserIcon },
planner: { label: "规划 Planner", icon: BrainIcon }, planner: { label: "规划 Planner", icon: BrainIcon },
worker: { label: "Workers", icon: RadioIcon }, worker: { label: "Workers", icon: RadioIcon },
system: { label: "系统审计", icon: HistoryIcon },
} as const; } as const;
// The main-agent session is the interactive entry point of this tab and has no // 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: "", 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:<id>). // keyForSession maps a UI Session → its stable store key (main | plan | intent:<id>).
function keyForSession(s: Session): string { function keyForSession(s: Session): string {
if (s.role === "mainagent") return "main"; if (s.role === "mainagent") return "main";
if (s.role === "planner") return "plan"; if (s.role === "planner") return "plan";
if (s.role === "system") return "system";
return `intent:${s.intent_id}`; return `intent:${s.intent_id}`;
} }
@@ -203,6 +239,8 @@ function intentStatus(state: string): SessionStatus {
return "exhausted"; return "exhausted";
case "stopped": case "stopped":
return "stopped"; return "stopped";
case "paused":
return "paused";
case "open": // 待领取,区别于执行中 case "open": // 待领取,区别于执行中
return "pending"; return "pending";
default: // running default: // running
@@ -220,9 +258,11 @@ function intentToSession(n: TaskNode): Session {
role: "worker", role: "worker",
title: label || `Intent ${n.id}`, title: label || `Intent ${n.id}`,
status: state, status: state,
live: state === "running", live: !n.inherited && state === "running",
last_activity: n.ts, last_activity: n.ts,
intent_id: n.id, intent_id: n.id,
source_task_id: n.source_task_id,
inherited: n.inherited,
}; };
} }
@@ -233,6 +273,10 @@ function SessionItem({
hasPending, hasPending,
unread, unread,
onClick, onClick,
onPause,
onResume,
onCancel,
controlling,
}: { }: {
s: Session; s: Session;
active: boolean; active: boolean;
@@ -240,6 +284,10 @@ function SessionItem({
hasPending?: boolean; hasPending?: boolean;
unread?: number; unread?: number;
onClick: () => void; onClick: () => void;
onPause?: () => void;
onResume?: () => void;
onCancel?: () => void;
controlling?: boolean;
}) { }) {
const icon = const icon =
s.role === "worker" ? ( s.role === "worker" ? (
@@ -248,36 +296,57 @@ function SessionItem({
<Loader2Icon className="size-3.5 animate-spin text-blue-500" /> <Loader2Icon className="size-3.5 animate-spin text-blue-500" />
) : null; ) : null;
const controllable = s.role === "worker" && !s.inherited && (s.status === "running" || s.status === "paused");
return ( return (
<button <div
onClick={onClick}
className={cn( className={cn(
"flex w-full items-center gap-1 rounded-md px-2 py-1.5 text-left text-sm transition-colors", "group/session flex w-full items-center rounded-md transition-colors",
active ? "bg-accent text-accent-foreground" : "hover:bg-accent/50", active ? "bg-accent text-accent-foreground" : "hover:bg-accent/50",
)} )}
> >
{icon ?? <span className="size-3.5 shrink-0" />} <button type="button" onClick={onClick} className="flex min-w-0 flex-1 items-center gap-1 px-2 py-1.5 text-left text-sm">
{s.intent_id && ( {icon ?? <span className="size-3.5 shrink-0" />}
<span className="shrink-0 rounded bg-muted px-1 py-0.5 font-mono text-[10px] tabular-nums text-muted-foreground"> {s.intent_id && (
#{s.intent_id} <span className="shrink-0 rounded bg-muted px-1 py-0.5 font-mono text-[10px] tabular-nums text-muted-foreground">
</span> #{s.intent_id}
</span>
)}
{s.inherited && s.source_task_id && (
<Badge variant="outline" className="shrink-0">
来源 #{s.source_task_id}
</Badge>
)}
<span className="min-w-0 flex-1 truncate">{displayTitle}</span>
{hasPending && <ShieldAlertIcon className="size-3.5 shrink-0 text-amber-500" />}
{!active && unread ? (
<span className="inline-flex min-w-4 items-center justify-center rounded-full bg-blue-500/15 px-1 text-[10px] font-medium tabular-nums text-blue-600 dark:text-blue-400">
{unread > 99 ? "99+" : unread}
</span>
) : null}
{s.live && (
<span className="inline-flex items-center gap-1 rounded bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400">
<span className="size-1 animate-pulse rounded-full bg-blue-500" />
实时
</span>
)}
</button>
{controllable && (
<div className="flex shrink-0 items-center gap-0.5 pr-1 opacity-100 sm:opacity-0 sm:transition-opacity sm:group-hover/session:opacity-100 sm:group-focus-within/session:opacity-100">
{s.status === "running" ? (
<Button type="button" variant="ghost" size="icon-xs" onClick={onPause} disabled={controlling} title="暂停 Worker" aria-label="暂停 Worker">
{controlling ? <Loader2Icon className="animate-spin" /> : <PauseIcon />}
</Button>
) : (
<Button type="button" variant="ghost" size="icon-xs" onClick={onResume} disabled={controlling} title="恢复 Worker" aria-label="恢复 Worker">
{controlling ? <Loader2Icon className="animate-spin" /> : <PlayIcon />}
</Button>
)}
<Button type="button" variant="ghost" size="icon-xs" onClick={onCancel} disabled={controlling} title="取消并清理 Worker 数据" aria-label="取消并清理 Worker 数据" className="text-destructive hover:text-destructive">
<Trash2Icon />
</Button>
</div>
)} )}
<span className="min-w-0 flex-1 truncate">{displayTitle}</span> </div>
{hasPending && (
<ShieldAlertIcon className="size-3.5 shrink-0 text-amber-500" />
)}
{!active && unread ? (
<span className="inline-flex min-w-4 items-center justify-center rounded-full bg-blue-500/15 px-1 text-[10px] font-medium tabular-nums text-blue-600 dark:text-blue-400">
{unread > 99 ? "99+" : unread}
</span>
) : null}
{s.live && (
<span className="inline-flex items-center gap-1 rounded bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400">
<span className="size-1 animate-pulse rounded-full bg-blue-500" />
实时
</span>
)}
</button>
); );
} }
@@ -295,6 +364,8 @@ export function SessionsTab({ taskId }: { taskId: string }) {
const [input, setInput] = React.useState(""); const [input, setInput] = React.useState("");
const [sending, setSending] = React.useState(false); const [sending, setSending] = React.useState(false);
const [stopping, setStopping] = React.useState(false); const [stopping, setStopping] = React.useState(false);
const [controllingIntent, setControllingIntent] = React.useState<string | null>(null);
const [cancelIntent, setCancelIntent] = React.useState<Session | null>(null);
// 方式1 文件上传:选好的附件(已落到任务工作目录 uploads/),随下条消息一起发。 // 方式1 文件上传:选好的附件(已落到任务工作目录 uploads/),随下条消息一起发。
const [attachments, setAttachments] = React.useState<ChatAttachment[]>([]); const [attachments, setAttachments] = React.useState<ChatAttachment[]>([]);
const [uploading, setUploading] = React.useState(false); const [uploading, setUploading] = React.useState(false);
@@ -313,6 +384,42 @@ export function SessionsTab({ taskId }: { taskId: string }) {
if (fileInputRef.current) fileInputRef.current.value = ""; 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 // SSE connection state — surfaced so a dropped realtime link is visible, never
// silently shown as "no messages". // silently shown as "no messages".
const [sseLive, setSseLive] = React.useState(false); const [sseLive, setSseLive] = React.useState(false);
@@ -326,6 +433,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
const esRef = React.useRef<EventSource | null>(null); const esRef = React.useRef<EventSource | null>(null);
const activeKeyRef = React.useRef("main"); // current session key (for SSE dispatch/unread) const activeKeyRef = React.useRef("main"); // current session key (for SSE dispatch/unread)
const atBottomRef = React.useRef(true); // transcript pinned to bottom? const atBottomRef = React.useRef(true); // transcript pinned to bottom?
const llmToastSeqRef = React.useRef<Set<number>>(new Set());
// Per-key request token: a stale response for a key is ignored (guards fast // 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 // 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). // 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; const token = (reqTokenRef.current[key] ?? 0) + 1;
reqTokenRef.current[key] = token; reqTokenRef.current[key] = token;
patchStore(key, (s) => ({ ...s, loading: true, error: undefined })); 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 api
.activityHistory(taskId, key, 0, PAGE) .activityHistory(taskId, key, 0, PAGE)
.then((r) => { .then((r) => {
@@ -424,8 +574,10 @@ export function SessionsTab({ taskId }: { taskId: string }) {
.then((r) => { .then((r) => {
if (alive) setTaskTokens(r.total); if (alive) setTaskTokens(r.total);
}) })
.catch(() => {}); .catch(() => {
load(); // Polling is best-effort; the next interval retries automatically.
});
void load();
const t = setInterval(load, 5000); const t = setInterval(load, 5000);
return () => { return () => {
alive = false; alive = false;
@@ -438,8 +590,10 @@ export function SessionsTab({ taskId }: { taskId: string }) {
const load = () => const load = () =>
api.interceptTask(taskId) api.interceptTask(taskId)
.then((rows) => { if (alive) setPendingIntercepts(rows.filter((r) => r.status === "pending")); }) .then((rows) => { if (alive) setPendingIntercepts(rows.filter((r) => r.status === "pending")); })
.catch(() => {}); .catch(() => {
load(); // Polling is best-effort; the next interval retries automatically.
});
void load();
const t = setInterval(load, 5000); const t = setInterval(load, 5000);
return () => { alive = false; clearInterval(t); }; return () => { alive = false; clearInterval(t); };
}, [taskId]); }, [taskId]);
@@ -490,9 +644,15 @@ export function SessionsTab({ taskId }: { taskId: string }) {
st.lastTs = st.items.length ? st.items[st.items.length - 1].ts : ""; st.lastTs = st.items.length ? st.items[st.items.length - 1].ts : "";
} }
buckets.main ??= { ...emptyState(), loaded: true }; buckets.main ??= { ...emptyState(), loaded: true };
buckets.system ??= { ...emptyState(), loaded: true };
setStore(buckets); setStore(buckets);
}) })
.catch(() => setStore({ main: { ...emptyState(), loaded: true } })); .catch(() =>
setStore({
main: { ...emptyState(), loaded: true },
system: { ...emptyState(), loaded: true },
}),
);
return () => { return () => {
alive = false; alive = false;
}; };
@@ -532,6 +692,17 @@ export function SessionsTab({ taskId }: { taskId: string }) {
} catch { } catch {
return; // ignore malformed frame 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); const k = sessionKeyOf(a);
setStore((prev) => { setStore((prev) => {
const cur = prev[k] ?? emptyState(); const cur = prev[k] ?? emptyState();
@@ -569,8 +740,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
esRef.current?.close(); esRef.current?.close();
esRef.current = null; esRef.current = null;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps }, [taskId, patchStore]);
}, [taskId]);
// ── worker (intent) session list — paged, poll first page lightly ─────────────── // ── worker (intent) session list — paged, poll first page lightly ───────────────
React.useEffect(() => { React.useEffect(() => {
@@ -583,8 +753,10 @@ export function SessionsTab({ taskId }: { taskId: string }) {
setIntents(r.items); setIntents(r.items);
setIntentsHasMore(r.hasMore); setIntentsHasMore(r.hasMore);
}) })
.catch(() => {}); .catch(() => {
load(); // Polling is best-effort; the next interval retries automatically.
});
void load();
const t = setInterval(load, 5000); const t = setInterval(load, 5000);
return () => { return () => {
active = false; active = false;
@@ -607,7 +779,9 @@ export function SessionsTab({ taskId }: { taskId: string }) {
}); });
setIntentsHasMore(r.hasMore); setIntentsHasMore(r.hasMore);
}) })
.catch(() => {}) .catch(() => {
// A later manual retry can fetch this page again.
})
.finally(() => setLoadingOlderIntents(false)); .finally(() => setLoadingOlderIntents(false));
}, [taskId, intents, olderIntents, loadingOlderIntents]); }, [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. // Worker agent_name format: "work#N · #intentID". Main/planner match by role key.
const hasPendingForSession = React.useCallback( const hasPendingForSession = React.useCallback(
(s: Session): boolean => { (s: Session): boolean => {
if (s.inherited) return false;
if (!pendingIntercepts.length) return false; if (!pendingIntercepts.length) return false;
if (s.role === "mainagent") return pendingIntercepts.some((r) => r.agent_name === "mainagent"); if (s.role === "mainagent") return pendingIntercepts.some((r) => r.agent_name === "mainagent");
if (s.role === "planner") return pendingIntercepts.some((r) => r.agent_name === "planner"); 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 }, { ...MAIN_SESSION, live: mainLive },
{ ...PLANNER_SESSION, live: plannerLive }, { ...PLANNER_SESSION, live: plannerLive },
...workerSessions, ...workerSessions,
SYSTEM_SESSION,
], ],
[workerSessions, mainLive, plannerLive], [workerSessions, mainLive, plannerLive],
); );
@@ -688,16 +864,19 @@ export function SessionsTab({ taskId }: { taskId: string }) {
mainagent: sessions.filter((s) => s.role === "mainagent"), mainagent: sessions.filter((s) => s.role === "mainagent"),
planner: sessions.filter((s) => s.role === "planner"), planner: sessions.filter((s) => s.role === "planner"),
worker: sessions.filter((s) => s.role === "worker"), 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 active = sessions.find((s) => s.id === activeId) ?? MAIN_SESSION;
const isMain = active.role === "mainagent"; const isMain = active.role === "mainagent";
const isPlanner = active.role === "planner"; const isPlanner = active.role === "planner";
const isSystem = active.role === "system";
const activeKey = keyForSession(active); const activeKey = keyForSession(active);
const activeState = store[activeKey]; const activeState = store[activeKey];
// Keep the SSE dispatcher's notion of the active session current, and lazily load // Keep the SSE dispatcher's notion of the active session current, and lazily load
// + clear unread whenever the active session changes. // + clear unread whenever the active session changes.
// biome-ignore lint/correctness/useExhaustiveDependencies: cache updates must not reactivate the current session.
React.useEffect(() => { React.useEffect(() => {
activeKeyRef.current = activeKey; activeKeyRef.current = activeKey;
const st = store[activeKey]; const st = store[activeKey];
@@ -706,7 +885,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
} else if (st.unread) { } else if (st.unread) {
patchStore(activeKey, (s) => ({ ...s, unread: 0 })); patchStore(activeKey, (s) => ({ ...s, unread: 0 }));
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeKey]); }, [activeKey]);
// Main agent is the human↔orchestrator CONSOLE: only the conversation (user msgs + // 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}`)); const pending = chatExtra.filter((a) => !seen.has(`${a.kind} ${a.summary}`));
return [...items, ...pending].sort((a, b) => a.seq - b.seq); 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 // 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. // message (the task handed to this worker), followed by its execution steps.
const intentTitle = sessionMeta.get(active.id)?.title ?? active.title; const intentTitle = sessionMeta.get(active.id)?.title ?? active.title;
@@ -731,9 +909,23 @@ export function SessionsTab({ taskId }: { taskId: string }) {
ts: active.last_activity || "", ts: active.last_activity || "",
kind: "intent", // LLM-generated objective — rendered as a distinct (non-human) bubble kind: "intent", // LLM-generated objective — rendered as a distinct (non-human) bubble
summary: intentTitle, summary: intentTitle,
source_task_id: active.source_task_id,
inherited: active.inherited,
}; };
return [intentMsg, ...items]; 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. // seq of this session's most-recent TodoWrite call — for the Todo popover.
const latestTodoSeq = React.useMemo(() => { const latestTodoSeq = React.useMemo(() => {
@@ -791,6 +983,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
null, null,
[], [],
); );
// biome-ignore lint/correctness/useExhaustiveDependencies: activeId intentionally rebinds the scroll listener.
React.useEffect(() => { React.useEffect(() => {
const vp = viewport(); const vp = viewport();
if (!vp) return; if (!vp) return;
@@ -802,6 +995,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
return () => vp.removeEventListener("scroll", onScroll); return () => vp.removeEventListener("scroll", onScroll);
}, [viewport, activeId, loadEarlier]); }, [viewport, activeId, loadEarlier]);
// open/switch a session → jump to the latest (bottom) // open/switch a session → jump to the latest (bottom)
// biome-ignore lint/correctness/useExhaustiveDependencies: activeId intentionally scrolls a newly selected session.
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
const vp = viewport(); const vp = viewport();
if (vp) { if (vp) {
@@ -810,6 +1004,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
} }
}, [activeId, viewport]); }, [activeId, viewport]);
// new activity → stick to bottom only if the user is already pinned there // 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(() => { React.useLayoutEffect(() => {
if (!atBottomRef.current) return; if (!atBottomRef.current) return;
const vp = viewport(); const vp = viewport();
@@ -819,7 +1014,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
function stop() { function stop() {
if (stopping) return; if (stopping) return;
setStopping(true); setStopping(true);
api.stopChat(taskId).finally(() => setStopping(false)); void api.stopChat(taskId).finally(() => setStopping(false));
} }
function send() { function send() {
@@ -906,7 +1101,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
</div> </div>
<ScrollArea type="auto" className="min-h-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!"> <ScrollArea type="auto" className="min-h-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!">
<div className="flex w-full flex-col gap-3 p-2"> <div className="flex w-full flex-col gap-3 p-2">
{(["mainagent", "planner", "worker"] as const).map((role) => { {(["mainagent", "planner", "system", "worker"] as const).map((role) => {
const items = grouped[role]; const items = grouped[role];
if (!items.length) return null; if (!items.length) return null;
const Meta = roleMeta[role]; const Meta = roleMeta[role];
@@ -927,11 +1122,16 @@ export function SessionsTab({ taskId }: { taskId: string }) {
hasPending={hasPendingForSession(s)} hasPending={hasPendingForSession(s)}
unread={store[keyForSession(s)]?.unread} unread={store[keyForSession(s)]?.unread}
onClick={() => setActiveId(s.id)} 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 && ( {role === "worker" && intentsHasMore && (
<button <button
type="button"
onClick={loadOlderIntents} onClick={loadOlderIntents}
disabled={loadingOlderIntents} disabled={loadingOlderIntents}
className="mt-0.5 flex items-center justify-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent/50" className="mt-0.5 flex items-center justify-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent/50"
@@ -977,6 +1177,11 @@ export function SessionsTab({ taskId }: { taskId: string }) {
</Tooltip> </Tooltip>
) : titleEl; ) : titleEl;
})()} })()}
{active.inherited && active.source_task_id && (
<Badge variant="outline">
来源任务 #{active.source_task_id} · 只读历史
</Badge>
)}
{active.live && ( {active.live && (
<span className="inline-flex items-center gap-1 rounded bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400"> <span className="inline-flex items-center gap-1 rounded bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400">
<span className="size-1 animate-pulse rounded-full bg-blue-500" /> <span className="size-1 animate-pulse rounded-full bg-blue-500" />
@@ -1070,48 +1275,66 @@ export function SessionsTab({ taskId }: { taskId: string }) {
))} ))}
</div> </div>
)} )}
<div className="flex items-end gap-2"> <input
<input ref={fileInputRef}
ref={fileInputRef} type="file"
type="file" multiple
multiple className="hidden"
className="hidden" onChange={(e) => void pickFiles(e.target.files)}
onChange={(e) => void pickFiles(e.target.files)} />
/> <InputGroup className="min-h-9 items-end has-disabled:opacity-100">
<Button <InputGroupAddon align="inline-start" className="self-end">
size="icon" <InputGroupButton
variant="ghost" size="icon-xs"
onClick={() => fileInputRef.current?.click()} variant="ghost"
disabled={mainLive || uploading} onClick={() => fileInputRef.current?.click()}
title="上传文件" disabled={mainLive || uploading}
> title="上传文件"
{uploading ? <Loader2Icon className="animate-spin" /> : <PaperclipIcon />} aria-label="上传文件"
</Button> >
<Textarea {uploading ? <Loader2Icon className="animate-spin" /> : <PaperclipIcon />}
className="max-h-40 min-h-9 flex-1 resize-none" </InputGroupButton>
</InputGroupAddon>
<InputGroupTextarea
rows={1} rows={1}
placeholder="给主 Agent 发消息,Enter 换行,Ctrl+Enter 发送…" aria-label="给主 Agent 发消息"
placeholder="给主 Agent 发消息,引导探索方向…"
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
// Enter 换行;只有 Ctrl/Cmd+Enter 才发送。 if (e.key !== "Enter" || e.shiftKey || e.nativeEvent.isComposing) return;
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault();
e.preventDefault(); if (!mainLive) send();
if (!mainLive) send();
}
}} }}
disabled={mainLive} disabled={mainLive}
className="max-h-36 min-h-9 overflow-y-auto"
/> />
{mainLive ? ( <InputGroupAddon align="inline-end" className="self-end">
<Button size="icon" variant="destructive" onClick={stop} disabled={stopping} title="停止当前执行"> {mainLive ? (
{stopping ? <Loader2Icon className="animate-spin" /> : <SquareIcon />} <InputGroupButton
</Button> size="icon-xs"
) : ( variant="destructive"
<Button size="icon" onClick={send} disabled={(!input.trim() && attachments.length === 0) || sending}> onClick={stop}
{sending ? <Loader2Icon className="animate-spin" /> : <SendIcon />} disabled={stopping}
</Button> title="停止当前执行"
)} aria-label="停止当前执行"
</div> >
{stopping ? <Loader2Icon className="animate-spin" /> : <SquareIcon />}
</InputGroupButton>
) : (
<InputGroupButton
size="icon-xs"
variant="default"
onClick={send}
disabled={(!input.trim() && attachments.length === 0) || sending}
title="发送消息"
aria-label="发送消息"
>
{sending ? <Loader2Icon className="animate-spin" /> : <SendIcon />}
</InputGroupButton>
)}
</InputGroupAddon>
</InputGroup>
</div> </div>
) : ( ) : (
<div className="flex items-center border-t px-4 py-2"> <div className="flex items-center border-t px-4 py-2">
@@ -1122,6 +1345,27 @@ export function SessionsTab({ taskId }: { taskId: string }) {
</div> </div>
)} )}
</div> </div>
<AlertDialog open={cancelIntent !== null} onOpenChange={(open) => !open && setCancelIntent(null)}>
<AlertDialogContent className="max-w-[min(32rem,calc(100vw-2rem))]">
<AlertDialogHeader>
<AlertDialogTitle>取消 Worker #{cancelIntent?.intent_id}?</AlertDialogTitle>
<AlertDialogDescription className="break-words whitespace-normal">
取消后将删除该意图、执行记录,以及该意图直接登记的事实和漏洞;全局资产、流量和其他 Planner 意图不会删除。此操作不可撤销。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>返回</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={!cancelIntent || controllingIntent !== null}
onClick={() => cancelIntent && void controlWorker(cancelIntent, "cancel")}
>
{controllingIntent ? <Loader2Icon className="animate-spin" /> : <Trash2Icon />}
确认取消并清理
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
</TooltipProvider> </TooltipProvider>
); );
+232 -50
View File
@@ -9,15 +9,28 @@ import {
PauseIcon, PauseIcon,
PlayIcon, PlayIcon,
BrainIcon, BrainIcon,
CheckIcon,
CircleAlertIcon,
} from "lucide-react"; } 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 { SidebarTrigger } from "@/components/ui/sidebar";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button"; 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { StatusBadge } from "@/components/status-badge"; import { StatusBadge } from "@/components/status-badge";
import { api } from "@/lib/api"; 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 { SessionsTab } from "./_tabs/sessions-tab";
import { OverviewTab } from "./_tabs/overview-tab"; import { OverviewTab } from "./_tabs/overview-tab";
@@ -39,6 +52,152 @@ const TABS = [
{ value: "report", label: "报告" }, { 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<string[]>(() => 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<HTMLDivElement>(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 (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
size="sm"
variant={exhausted ? "destructive" : "outline"}
aria-label="查看或切换任务 LLM 配置"
title={currentTitle}
>
<BrainIcon data-icon="inline-start" />
<span className="hidden max-w-36 truncate lg:inline">{currentLabel}</span>
{backupCount > 0 && <span className="hidden text-muted-foreground xl:inline">+{backupCount}</span>}
</Button>
</PopoverTrigger>
<PopoverContent
ref={popoverContentRef}
align="start"
className="w-[min(28rem,calc(100vw-2rem))] gap-4 p-4"
>
<PopoverHeader>
<PopoverTitle>任务 LLM 配置链</PopoverTitle>
<PopoverDescription>{editorDescription}</PopoverDescription>
</PopoverHeader>
{exhausted && (
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>配置链额度已耗尽</AlertTitle>
<AlertDescription>
{task.llm_failover_reason ?? "所有已选配置均被判定为额度不足。保存配置链可重置故障状态。"}
</AlertDescription>
</Alert>
)}
<TaskLLMProfileChain
profiles={profiles}
value={profileIDs}
onValueChange={handleProfileIDsChange}
activeProfileId={activeProfileID}
onActiveProfileChange={setActiveProfileID}
inputId="task-llm-profiles"
disabled={!editable || saving}
portalContainer={popoverContentRef}
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(false)}>
关闭
</Button>
{editable && (
<Button type="button" size="sm" onClick={save} disabled={saving}>
{saving && <Spinner data-icon="inline-start" />}
{saveLabel}
</Button>
)}
</div>
</PopoverContent>
</Popover>
);
}
function TaskDetailInner() { function TaskDetailInner() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const id = searchParams.get("id") ?? ""; const id = searchParams.get("id") ?? "";
@@ -47,42 +206,62 @@ function TaskDetailInner() {
const [loaded, setLoaded] = React.useState(false); const [loaded, setLoaded] = React.useState(false);
const [tab, setTab] = React.useState("sessions"); const [tab, setTab] = React.useState("sessions");
const [interceptPendingCount, setInterceptPendingCount] = React.useState(0); const [interceptPendingCount, setInterceptPendingCount] = React.useState(0);
const [profiles, setProfiles] = React.useState<LLMProfile[]>([]);
React.useEffect(() => {
api
.llmProfiles()
.then(setProfiles)
.catch(() => setProfiles([]));
}, []);
React.useEffect(() => { React.useEffect(() => {
let alive = true; let alive = true;
const load = () => const load = () =>
api.interceptTask(id) api
.then((rows) => { if (alive) setInterceptPendingCount(rows.filter((r) => r.status === "pending").length); }) .interceptTask(id)
.catch(() => {}); .then((rows) => {
load(); 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); const t = setInterval(load, 5000);
return () => { alive = false; clearInterval(t); }; return () => {
alive = false;
clearInterval(t);
};
}, [id]); }, [id]);
const load = React.useCallback(() => { const load = React.useCallback(() => {
Promise.all([ Promise.all([api.tasks(), api.stats(id).catch(() => null)])
api.tasks(),
api.stats(id).catch(() => null),
])
.then(([r, s]) => { .then(([r, s]) => {
const base = r.tasks.find((t) => t.id === id) ?? null; const base = r.tasks.find((t) => t.id === id) ?? null;
const at = (s as { active_task?: Partial<Task> & { paused?: boolean } } | null)?.active_task; const at = s?.active_task;
if (base && at) { if (base && at) {
base.in_flight = at.in_flight; base.in_flight = at.in_flight;
base.goals_total = at.goals_total; base.goals_total = at.goals_total;
base.goals_met = at.goals_met; 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; base.paused = at.paused;
} }
setTask(base); setTask(base);
setPaused(at?.paused ?? base?.paused ?? false); setPaused(at?.paused ?? base?.paused ?? false);
}) })
.catch(() => {}) .catch(() => {
// Keep the last rendered task state during a transient poll failure.
})
.finally(() => setLoaded(true)); .finally(() => setLoaded(true));
}, [id]); }, [id]);
React.useEffect(() => { load(); }, [load]); React.useEffect(() => {
load();
const timer = setInterval(load, 5000);
return () => clearInterval(timer);
}, [load]);
async function togglePause() { async function togglePause() {
if (task && ["done", "failed", "timeout"].includes(task.status)) return;
const next = !paused; const next = !paused;
try { try {
await api.controlTask(id, next ? "pause" : "resume"); 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 = <PauseIcon data-icon="inline-start" />;
let controlLabel = "暂停";
if (terminal) {
controlVariant = "secondary";
controlIcon = <CheckIcon data-icon="inline-start" />;
controlLabel = completed ? "已完成" : "已结束";
} else if (paused) {
controlVariant = "default";
controlIcon = <PlayIcon data-icon="inline-start" />;
controlLabel = "恢复";
}
return ( return (
<Tabs <Tabs value={tab} onValueChange={setTab} className="flex flex-1 flex-col gap-0">
value={tab}
onValueChange={setTab}
className="flex flex-1 flex-col gap-0"
>
{/* Top fixed area */} {/* Top fixed area */}
<header className="sticky top-0 z-10 flex flex-col gap-2 border-b bg-background/95 px-4 py-2.5 backdrop-blur lg:px-6"> <header className="sticky top-0 z-10 flex flex-col gap-2 border-b bg-background/95 px-4 py-2.5 backdrop-blur lg:px-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -125,43 +314,36 @@ function TaskDetailInner() {
<ArrowLeftIcon /> <ArrowLeftIcon />
</Link> </Link>
</Button> </Button>
<h1 className="max-w-md truncate text-sm font-semibold" title={task.description}> <h1 className="min-w-0 flex-1 truncate text-sm font-semibold" title={task.description}>
{task.description} {task.description}
</h1> </h1>
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground"> <code className="hidden rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground sm:inline">
{task.id} {task.id}
</code> </code>
<Separator orientation="vertical" className="mx-1 h-4" /> <Separator orientation="vertical" className="mx-1 hidden h-4 sm:block" />
{/* status pills */} <TaskLLMControl task={task} profiles={profiles} onUpdated={load} />
<span className="inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs"> <StatusBadge domain={terminal ? "task" : "engine"} value={terminal ? task.status : engineMode} dot />
<BrainIcon className="size-3.5 text-emerald-500" /> LLM 已配置 <Button size="sm" variant={controlVariant} onClick={togglePause} disabled={terminal}>
</span> {controlIcon}
<StatusBadge domain="engine" value={engineMode} dot /> {controlLabel}
<div className="ml-auto"> </Button>
<Button
size="sm"
variant={paused ? "default" : "outline"}
onClick={togglePause}
>
{paused ? <PlayIcon /> : <PauseIcon />}
{paused ? "恢复" : "暂停"}
</Button>
</div>
</div> </div>
<p className="truncate text-xs text-muted-foreground">{task.goal}</p> <p className="truncate text-xs text-muted-foreground">{task.goal}</p>
{/* Tabs */} {/* Tabs */}
<TabsList variant="default"> <div className="no-scrollbar min-w-0 overflow-x-auto">
{TABS.map((t) => ( <TabsList variant="default" className="min-w-max">
<TabsTrigger key={t.value} value={t.value}> {TABS.map((t) => (
{t.label} <TabsTrigger key={t.value} value={t.value}>
{t.value === "intercept" && interceptPendingCount > 0 && ( {t.label}
<span className="ml-1.5 inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-amber-500 px-1 text-[10px] font-semibold leading-none text-white"> {t.value === "intercept" && interceptPendingCount > 0 && (
{interceptPendingCount > 99 ? "99+" : interceptPendingCount} <span className="ml-1.5 inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-amber-500 px-1 text-[10px] font-semibold leading-none text-white">
</span> {interceptPendingCount > 99 ? "99+" : interceptPendingCount}
)} </span>
</TabsTrigger> )}
))} </TabsTrigger>
</TabsList> ))}
</TabsList>
</div>
</header> </header>
{/* Tab content */} {/* Tab content */}
+436 -208
View File
@@ -17,6 +17,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { StatusBadge } from "@/components/status-badge"; import { StatusBadge } from "@/components/status-badge";
import { TaskLLMProfileChain } from "@/components/task-llm-profile-chain";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -41,8 +42,29 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } 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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { import {
@@ -71,13 +93,21 @@ import {
import { import {
Select, Select,
SelectContent, SelectContent,
SelectGroup,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { TablePagination } from "@/components/table-pagination"; import { TablePagination } from "@/components/table-pagination";
import { api } from "@/lib/api"; 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. // fmtBytes renders a human file size for the upload manifest.
function fmtBytes(n: number): string { 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`; 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 / // 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 变化. // worker), so the list has to be pulled; 10s is plenty for status / 进度 / token 变化.
const POLL_MS = 10_000; const POLL_MS = 10_000;
const MAX_SOURCE_TASKS = 8;
// fmtTokens renders a compact token count (1234 → 1.2k, 2_000_000 → 2M). // fmtTokens renders a compact token count (1234 → 1.2k, 2_000_000 → 2M).
function fmtTokens(n: number): string { function fmtTokens(n: number): string {
@@ -144,6 +172,17 @@ function taskDuration(task: Task, nowSec: number): number {
return end > start ? end - start : 0; 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 // fmtDateTime renders a unix-seconds timestamp as a compact local date-time
// (MM-DD HH:mm), or "—" when unset. // (MM-DD HH:mm), or "—" when unset.
function fmtDateTime(unix?: number): string { function fmtDateTime(unix?: number): string {
@@ -185,6 +224,7 @@ export default function TasksPage() {
}, [tasks, query, statusFilter]); }, [tasks, query, statusFilter]);
// reset to page 1 whenever filters change // reset to page 1 whenever filters change
// biome-ignore lint/correctness/useExhaustiveDependencies: both filters intentionally reset pagination.
React.useEffect(() => { setPage(1); }, [query, statusFilter]); React.useEffect(() => { setPage(1); }, [query, statusFilter]);
const paginated = React.useMemo( const paginated = React.useMemo(
@@ -206,7 +246,9 @@ export default function TasksPage() {
lastRef.current = sig; lastRef.current = sig;
setTasks(next); setTasks(next);
}) })
.catch(() => {}); .catch(() => {
// Polling is best-effort; the next interval retries automatically.
});
}, []); }, []);
React.useEffect(() => { React.useEffect(() => {
@@ -227,18 +269,20 @@ export default function TasksPage() {
return () => clearInterval(i); return () => clearInterval(i);
}, [hasRunning]); }, [hasRunning]);
const deleteTask = React.useCallback( const deleteTask = React.useCallback(async (id: string, options: DeleteTaskOptions) => {
async (id: string, opts: DeleteOpts) => { try {
try { const result = await api.deleteTask(id, options);
await api.deleteTask(id, opts); if (result.cleanup_warning) {
toast.success("任务已删除"); toast.warning(`${deleteSummary(result)};部分外部数据清理未完成:${result.cleanup_warning}`);
load(); } else {
} catch (e) { toast.success(deleteSummary(result));
toast.error("删除失败:" + (e as Error).message);
} }
}, load();
[load], } catch (e) {
); toast.error("删除失败:" + (e as Error).message);
throw e;
}
}, [load]);
return ( return (
<Card> <Card>
@@ -268,19 +312,21 @@ export default function TasksPage() {
<SelectValue placeholder="状态" /> <SelectValue placeholder="状态" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">全部状态</SelectItem> <SelectGroup>
{STATUS_OPTIONS.map((s) => ( <SelectItem value="all">全部状态</SelectItem>
<SelectItem key={s.value} value={s.value}> {STATUS_OPTIONS.map((s) => (
{s.label} <SelectItem key={s.value} value={s.value}>
</SelectItem> {s.label}
))} </SelectItem>
))}
</SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
<span className="text-muted-foreground text-xs tabular-nums"> <span className="text-muted-foreground text-xs tabular-nums">
{filtered.length}/{tasks.length} 条 {filtered.length}/{tasks.length} 条
</span> </span>
<ConcurrencySettingsDialog /> <ConcurrencySettingsDialog />
<CreateTaskSheet onCreated={load} /> <CreateTaskSheet tasks={tasks} onCreated={load} />
</div> </div>
{tasks.length === 0 ? ( {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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline" className="ml-auto" aria-label="任务并发设置">
<SlidersHorizontalIcon /> 并发设置
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>任务并发限制</DialogTitle>
<DialogDescription>
限制同时运行的任务数量。达到上限后,新任务会按创建顺序排队并在空位出现时自动启动。
</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-2">
<div className="flex items-center justify-between gap-4">
<div className="grid gap-1">
<Label htmlFor="task-concurrency-enabled">开启任务并发限制</Label>
<span className="text-muted-foreground text-xs">默认关闭</span>
</div>
<Switch id="task-concurrency-enabled" checked={enabled} onCheckedChange={setEnabled} disabled={loading} />
</div>
{enabled && (
<div className="grid gap-2">
<Label htmlFor="task-concurrency-limit">同时运行上限</Label>
<Input id="task-concurrency-limit" type="number" min={1} className="w-32" value={limit} onChange={(event) => setLimit(event.target.value)} disabled={loading} />
</div>
)}
</div>
<DialogFooter>
<DialogClose asChild><Button variant="outline">取消</Button></DialogClose>
<Button onClick={save} disabled={loading || saving}>
{saving && <Loader2Icon className="animate-spin" />} 保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// TaskRow renders one row of the task table. Memoized so the per-second 运行时长 tick and // 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 // 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 // is 20 rows × (StatusBadge + Link + a Radix AlertDialog tree), far too heavy to rebuild
// wholesale on every parent render. // 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/<id>(产物 + 上传)" },
];
const TaskRow = React.memo(function TaskRow({ const TaskRow = React.memo(function TaskRow({
task, task,
nowSec, nowSec,
@@ -352,14 +464,8 @@ const TaskRow = React.memo(function TaskRow({
}: { }: {
task: Task; task: Task;
nowSec: number; nowSec: number;
onDelete: (id: string, opts: DeleteOpts) => void; onDelete: (id: string, options: DeleteTaskOptions) => Promise<void>;
}) { }) {
const [delOpts, setDelOpts] = React.useState<DeleteOpts>({
assets: false,
findings: false,
traffic: false,
files: false,
});
return ( return (
<TableRow className="group border-border/60"> <TableRow className="group border-border/60">
<TableCell> <TableCell>
@@ -425,159 +531,183 @@ const TaskRow = React.memo(function TaskRow({
进入 <ArrowRightIcon /> 进入 <ArrowRightIcon />
</Link> </Link>
</Button> </Button>
<AlertDialog> <DeleteTaskDialog task={task} onDelete={onDelete} />
<AlertDialogTrigger asChild>
<Button size="icon" variant="outline" aria-label="删除任务">
<Trash2Icon className="text-destructive" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除任务 #{task.id}?</AlertDialogTitle>
<AlertDialogDescription className="break-words">
{task.description ? (
<>
「
<span className="break-all">
{task.description.length > 80
? task.description.slice(0, 80) + "…"
: task.description}
</span>
」
</>
) : (
"该任务"
)}
的执行记录、会话与产物将被删除,此操作不可撤销。
</AlertDialogDescription>
</AlertDialogHeader>
{/* 额外清理项:默认都不选,勾选后连带删除。全局资产图默认保留。 */}
<div className="grid gap-2 rounded-md border bg-muted/30 p-3">
<span className="text-xs font-medium text-muted-foreground">
同时删除以下关联数据(默认不删):
</span>
{DELETE_EXTRAS.map((opt) => (
<label key={opt.key} className="flex items-start gap-2 text-sm">
<Checkbox
className="mt-0.5"
checked={delOpts[opt.key]}
onCheckedChange={(v) =>
setDelOpts((s) => ({ ...s, [opt.key]: v === true }))
}
/>
<span className="grid gap-0.5">
<span>{opt.label}</span>
<span className="text-xs text-muted-foreground">{opt.hint}</span>
</span>
</label>
))}
</div>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={() => onDelete(task.id, delOpts)}>
删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
); );
}); });
// ConcurrencySettingsDialog is the 齿轮 button left of 新建任务: toggle the const emptyDeleteOptions = (): DeleteTaskOptions => ({
// simultaneous-running-task cap and pick the limit. Default off; default limit 5. delete_assets: false,
// Persisted globally via /api/settings — applies to tasks created afterwards. delete_traffic: false,
function ConcurrencySettingsDialog() { 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<void>;
}) {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [enabled, setEnabled] = React.useState(false); const [deleting, setDeleting] = React.useState(false);
const [limit, setLimit] = React.useState("5"); const [options, setOptions] = React.useState<DeleteTaskOptions>(emptyDeleteOptions);
const [loading, setLoading] = React.useState(false);
const [saving, setSaving] = React.useState(false);
// 每次打开都拉取当前值,避免显示陈旧状态。 const selectedCount = deleteOptionKeys.filter((key) => options[key]).length;
React.useEffect(() => { const allSelected = selectedCount === deleteOptionKeys.length;
if (!open) return; let allChecked: boolean | "indeterminate" = false;
setLoading(true); if (allSelected) {
api allChecked = true;
.settings() } else if (selectedCount > 0) {
.then((s) => { allChecked = "indeterminate";
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 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 ( return (
<Dialog open={open} onOpenChange={setOpen}> <AlertDialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild> <AlertDialogTrigger asChild>
<Button size="sm" variant="outline" className="ml-auto" aria-label="任务并发设置"> <Button size="icon" variant="outline" aria-label="删除任务">
<SlidersHorizontalIcon /> 并发设置 <Trash2Icon className="text-destructive" />
</Button> </Button>
</DialogTrigger> </AlertDialogTrigger>
<DialogContent className="sm:max-w-md"> <AlertDialogContent>
<DialogHeader> <AlertDialogHeader>
<DialogTitle>任务并发限制</DialogTitle> <AlertDialogTitle>确认删除任务 #{task.id}?</AlertDialogTitle>
<DialogDescription> <AlertDialogDescription className="break-words">
限制同时「运行中」的任务数量。关闭时不限制;开启后新建任务若已达上限,会进入「排队中」,有空位时自动开始。 {task.description ? (
</DialogDescription> <>「<span className="break-all">{task.description.length > 80 ? `${task.description.slice(0, 80)}…` : task.description}</span>」</>
</DialogHeader> ) : "该任务"}
的执行记录与探索链路将被永久删除。
<div className="grid gap-5 py-2"> </AlertDialogDescription>
<div className="flex items-center justify-between gap-4"> </AlertDialogHeader>
<div className="grid gap-1"> <FieldSet disabled={deleting}>
<Label htmlFor="conc-enabled">开启任务并发限制</Label> <FieldLegend variant="label">同时清理关联数据</FieldLegend>
<span className="text-muted-foreground text-xs">默认关闭(不限制并发)</span> <FieldGroup className="gap-3">
</div> <Field orientation="horizontal">
<Switch id="conc-enabled" checked={enabled} onCheckedChange={setEnabled} disabled={loading} /> <Checkbox
</div> id={`delete-all-${task.id}`}
checked={allChecked}
{enabled && ( onCheckedChange={(checked) => updateAllOptions(checked === true)}
<div className="grid gap-2">
<Label htmlFor="conc-limit">并发数量(同时运行的任务上限)</Label>
<Input
id="conc-limit"
type="number"
min={1}
className="w-32"
value={limit}
onChange={(e) => setLimit(e.target.value)}
disabled={loading}
/> />
<span className="text-muted-foreground text-xs">开启后默认 5,最小 1。</span> <FieldContent>
</div> <FieldLabel htmlFor={`delete-all-${task.id}`}>全部删除</FieldLabel>
)} <FieldDescription>选中下方全部关联数据,包括资产、流量、文件、漏洞和 LLM 请求/响应记录。</FieldDescription>
</div> </FieldContent>
</Field>
<DialogFooter> <Field orientation="horizontal">
<DialogClose asChild> <Checkbox
<Button variant="outline">取消</Button> id={`delete-assets-${task.id}`}
</DialogClose> checked={options.delete_assets}
<Button onClick={save} disabled={loading || saving}> onCheckedChange={(checked) => updateOption("delete_assets", checked === true)}
{saving ? <Loader2Icon className="animate-spin" /> : null} />
保存 <FieldContent>
</Button> <FieldLabel htmlFor={`delete-assets-${task.id}`}>关联资产</FieldLabel>
</DialogFooter> <FieldDescription>删除仅属于该任务的资产;共享资产只解除当前任务关联。</FieldDescription>
</DialogContent> </FieldContent>
</Dialog> </Field>
<Field orientation="horizontal">
<Checkbox
id={`delete-traffic-${task.id}`}
checked={options.delete_traffic}
onCheckedChange={(checked) => updateOption("delete_traffic", checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={`delete-traffic-${task.id}`}>关联流量</FieldLabel>
<FieldDescription>按关联资产的精确主机名删除;仍被其他任务引用的共享主机流量会保留。</FieldDescription>
</FieldContent>
</Field>
<Field orientation="horizontal">
<Checkbox
id={`delete-files-${task.id}`}
checked={options.delete_files}
onCheckedChange={(checked) => updateOption("delete_files", checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={`delete-files-${task.id}`}>任务文件</FieldLabel>
<FieldDescription>删除该任务工作目录中的上传文件、命令输出和其他产物。</FieldDescription>
</FieldContent>
</Field>
<Field orientation="horizontal">
<Checkbox
id={`delete-findings-${task.id}`}
checked={options.delete_findings}
onCheckedChange={(checked) => updateOption("delete_findings", checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={`delete-findings-${task.id}`}>关联漏洞</FieldLabel>
<FieldDescription>永久删除该任务产生的独立漏洞记录与漏洞报告。</FieldDescription>
</FieldContent>
</Field>
<Field orientation="horizontal">
<Checkbox
id={`delete-llm-records-${task.id}`}
checked={options.delete_llm_records}
onCheckedChange={(checked) => updateOption("delete_llm_records", checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={`delete-llm-records-${task.id}`}>LLM 请求/响应记录</FieldLabel>
<FieldDescription>永久删除该任务录制的 LLM 请求、响应、Token 与错误详情。</FieldDescription>
</FieldContent>
</Field>
</FieldGroup>
</FieldSet>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deleting}
onClick={(event) => {
event.preventDefault();
void handleDelete();
}}
>
{deleting && <Spinner data-icon="inline-start" />}
{deleting ? "删除中" : "删除"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
); );
} }
@@ -585,12 +715,81 @@ function ConcurrencySettingsDialog() {
// 描述/目标 held by the page component every keystroke re-rendered the whole task table // 描述/目标 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 // behind the drawer (plus its sticky column and 20 AlertDialog trees), which showed up as
// input lag. Now typing only re-renders the drawer. // 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<HTMLElement | null>;
}) {
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 (
<Combobox
items={taskIDs}
itemToStringValue={(taskID) => {
const task = tasksByID.get(taskID);
return task ? `${task.id} ${task.description} ${task.goal}` : taskID;
}}
multiple
value={value}
onValueChange={handleValueChange}
>
<ComboboxChips>
<ComboboxValue>
{value.map((taskID) => (
<ComboboxChip key={taskID}>#{taskID}</ComboboxChip>
))}
</ComboboxValue>
<ComboboxChipsInput
id="source-tasks"
placeholder={atLimit ? `最多关联 ${MAX_SOURCE_TASKS} 个任务` : "搜索任务 ID、描述或目标"}
disabled={atLimit}
/>
</ComboboxChips>
<ComboboxContent portalContainer={portalContainer}>
<ComboboxEmpty>没有匹配的任务</ComboboxEmpty>
<ComboboxList>
{(taskID) => {
const task = tasksByID.get(taskID);
return (
<ComboboxItem key={taskID} value={taskID} disabled={atLimit && !value.includes(taskID)}>
<div className="flex min-w-0 flex-1 items-center gap-2">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
#{taskID} · {task?.description ?? "未知任务"}
</p>
{task?.goal && <p className="text-muted-foreground truncate text-xs">{task.goal}</p>}
</div>
{task && <StatusBadge domain="task" value={task.status} />}
</div>
</ComboboxItem>
);
}}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
}
function CreateTaskSheet({ tasks, onCreated }: { tasks: Task[]; onCreated: () => void }) {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [description, setDescription] = React.useState(""); const [description, setDescription] = React.useState("");
const [goal, setGoal] = React.useState(""); const [goal, setGoal] = React.useState("");
const [profiles, setProfiles] = React.useState<LLMProfile[]>([]); const [profiles, setProfiles] = React.useState<LLMProfile[]>([]);
const [llmProfile, setLlmProfile] = React.useState<string>(ACTIVE_PROFILE); // sentinel = active const [sourceTaskIDs, setSourceTaskIDs] = React.useState<string[]>([]);
const [llmProfileIDs, setLLMProfileIDs] = React.useState<string[]>([]);
const [creating, setCreating] = React.useState(false);
const [timeoutMin, setTimeoutMin] = React.useState(""); // 任务级超时(分钟);空/0 = 不限时 const [timeoutMin, setTimeoutMin] = React.useState(""); // 任务级超时(分钟);空/0 = 不限时
const [heartbeatMin, setHeartbeatMin] = React.useState("10"); // planner 心跳(分钟);默认10,下限10(与后端一致) const [heartbeatMin, setHeartbeatMin] = React.useState("10"); // planner 心跳(分钟);默认10,下限10(与后端一致)
const [seedFirstIntent, setSeedFirstIntent] = React.useState(false); // 创建时下发种子意图,worker 免等首轮 planner 直接开跑;默认关闭,走标准先规划再执行 const [seedFirstIntent, setSeedFirstIntent] = React.useState(false); // 创建时下发种子意图,worker 免等首轮 planner 直接开跑;默认关闭,走标准先规划再执行
@@ -599,6 +798,7 @@ function CreateTaskSheet({ onCreated }: { onCreated: () => void }) {
const [uploadCount, setUploadCount] = React.useState(0); const [uploadCount, setUploadCount] = React.useState(0);
const draftIdRef = React.useRef<string>(""); const draftIdRef = React.useRef<string>("");
const fileInputRef = React.useRef<HTMLInputElement>(null); const fileInputRef = React.useRef<HTMLInputElement>(null);
const sheetContentRef = React.useRef<HTMLDivElement>(null);
// load LLM profiles once for the create-task profile picker. // load LLM profiles once for the create-task profile picker.
React.useEffect(() => { React.useEffect(() => {
@@ -633,15 +833,28 @@ function CreateTaskSheet({ onCreated }: { onCreated: () => void }) {
toast.error("请填写描述与目标"); toast.error("请填写描述与目标");
return; return;
} }
if (sourceTaskIDs.length > MAX_SOURCE_TASKS) {
toast.error(`最多关联 ${MAX_SOURCE_TASKS} 个来源任务`);
return;
}
setCreating(true);
try { try {
const pid = llmProfile === ACTIVE_PROFILE ? undefined : Number(llmProfile);
const timeoutSec = Math.max(0, Math.floor(Number(timeoutMin) || 0)) * 60; const timeoutSec = Math.max(0, Math.floor(Number(timeoutMin) || 0)) * 60;
const heartbeatSec = Math.max(10, Math.floor(Number(heartbeatMin) || 10)) * 60; // 下限 10min,与后端归一一致 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("任务已创建"); toast.success("任务已创建");
setDescription(""); setDescription("");
setGoal(""); setGoal("");
setLlmProfile(ACTIVE_PROFILE); setSourceTaskIDs([]);
setLLMProfileIDs([]);
setTimeoutMin(""); setTimeoutMin("");
setHeartbeatMin("10"); setHeartbeatMin("10");
setSeedFirstIntent(false); setSeedFirstIntent(false);
@@ -651,19 +864,22 @@ function CreateTaskSheet({ onCreated }: { onCreated: () => void }) {
onCreated(); onCreated();
} catch (e) { } catch (e) {
toast.error("创建失败:" + (e as Error).message); toast.error("创建失败:" + (e as Error).message);
} finally {
setCreating(false);
} }
} }
return ( return (
<Sheet open={open} onOpenChange={setOpen}> <Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild> <SheetTrigger asChild>
<Button size="sm"> <Button size="sm" className="ml-auto">
<PlusIcon /> 新建任务 <PlusIcon /> 新建任务
</Button> </Button>
</SheetTrigger> </SheetTrigger>
{/* 45vw 宽的右侧抽屉:整屏高度可滚动,长表单不再受弹窗高度限制。窄屏退化为全宽。 {/* 45vw 宽的右侧抽屉:整屏高度可滚动,长表单不再受弹窗高度限制。窄屏退化为全宽。
内容为 flex 列:头/脚固定,中间字段区 flex-1 独立滚动。 */} 内容为 flex 列:头/脚固定,中间字段区 flex-1 独立滚动。 */}
<SheetContent <SheetContent
ref={sheetContentRef}
side="right" side="right"
className="w-full! max-w-none! gap-0 p-0 sm:w-[45vw]! sm:max-w-[45vw]!" className="w-full! max-w-none! gap-0 p-0 sm:w-[45vw]! sm:max-w-[45vw]!"
> >
@@ -723,26 +939,31 @@ function CreateTaskSheet({ onCreated }: { onCreated: () => void }) {
onChange={(e) => setGoal(e.target.value)} onChange={(e) => setGoal(e.target.value)}
/> />
</div> </div>
<div className="grid gap-2"> <Field>
<Label htmlFor="llm-profile">LLM 配置</Label> <FieldLabel htmlFor="source-tasks">关联任务</FieldLabel>
<Select value={llmProfile} onValueChange={setLlmProfile}> <SourceTaskPicker
<SelectTrigger id="llm-profile"> tasks={tasks}
<SelectValue /> value={sourceTaskIDs}
</SelectTrigger> onValueChange={setSourceTaskIDs}
<SelectContent> portalContainer={sheetContentRef}
<SelectItem value={ACTIVE_PROFILE}>使用激活配置(默认)</SelectItem> />
{profiles.map((p) => ( <FieldDescription>
<SelectItem key={p.id} value={String(p.id)}> 最多关联 {MAX_SOURCE_TASKS} 个任务。实时只读继承所选任务的持久化黑板、资产范围及相关流量;新任务写入独立黑板。
{p.name} </FieldDescription>
{p.is_default ? "(激活)" : ""} · {p.model} </Field>
</SelectItem> <Field>
))} <FieldLabel htmlFor="llm-profiles">LLM 配置链</FieldLabel>
</SelectContent> <TaskLLMProfileChain
</Select> profiles={profiles}
<p className="text-muted-foreground text-xs"> value={llmProfileIDs}
本任务的 planner/worker 使用所选配置运行;对话仍用激活配置。 onValueChange={setLLMProfileIDs}
</p> inputId="llm-profiles"
</div> portalContainer={sheetContentRef}
/>
<FieldDescription>
按列表顺序故障转移;第一项为当前配置,仅在明确额度不足时切换下一项。
</FieldDescription>
</Field>
{/* 高级参数默认折叠:超时/心跳/首个意图,展开才占空间,常用路径保持清爽。 */} {/* 高级参数默认折叠:超时/心跳/首个意图,展开才占空间,常用路径保持清爽。 */}
<Collapsible> <Collapsible>
@@ -785,8 +1006,12 @@ function CreateTaskSheet({ onCreated }: { onCreated: () => void }) {
</p> </p>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<label className="flex items-center gap-2 text-sm"> <label htmlFor="seed-first-intent" className="flex items-center gap-2 text-sm">
<Checkbox checked={seedFirstIntent} onCheckedChange={(v) => setSeedFirstIntent(!!v)} /> <Checkbox
id="seed-first-intent"
checked={seedFirstIntent}
onCheckedChange={(v) => setSeedFirstIntent(!!v)}
/>
直接下发首个意图(描述+目标) 直接下发首个意图(描述+目标)
</label> </label>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
@@ -802,7 +1027,10 @@ function CreateTaskSheet({ onCreated }: { onCreated: () => void }) {
<SheetClose asChild> <SheetClose asChild>
<Button variant="outline">取消</Button> <Button variant="outline">取消</Button>
</SheetClose> </SheetClose>
<Button onClick={createTask}>创建</Button> <Button onClick={createTask} disabled={creating || uploading}>
{creating && <Spinner data-icon="inline-start" />}
{creating ? "创建中" : "创建"}
</Button>
</SheetFooter> </SheetFooter>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
+4 -3
View File
@@ -23,6 +23,7 @@ import {
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Markdown } from "@/components/markdown";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Agent, AgentDetail, AgentTrigger, MCPServer, PromptVar, PromptVersion, Settings, SkillItem, Tool } from "@/lib/types"; import type { Agent, AgentDetail, AgentTrigger, MCPServer, PromptVar, PromptVersion, Settings, SkillItem, Tool } from "@/lib/types";
@@ -397,9 +398,9 @@ export function AgentEditor({ agentKey, onSaved }: { agentKey: string; onSaved?:
<DialogTitle>渲染预览</DialogTitle> <DialogTitle>渲染预览</DialogTitle>
<DialogDescription>所有 {`{{.Var}}`} 已由后端用示例值替换。</DialogDescription> <DialogDescription>所有 {`{{.Var}}`} 已由后端用示例值替换。</DialogDescription>
</DialogHeader> </DialogHeader>
<pre className="bg-muted max-h-[60vh] overflow-auto whitespace-pre-wrap rounded-md p-3 font-mono text-xs"> <div className="max-h-[60vh] overflow-auto rounded-md border bg-muted/30 p-3">
{preview} <Markdown text={preview} />
</pre> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Button size="sm" onClick={savePrompt}> <Button size="sm" onClick={savePrompt}>
+18 -3
View File
@@ -31,6 +31,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { StatusBadge } from "@/components/status-badge"; import { StatusBadge } from "@/components/status-badge";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { import {
@@ -235,7 +236,7 @@ function ExploreNode({ data, selected }: NodeProps<ExploreRFNode>) {
const Icon = meta.icon; const Icon = meta.icon;
const showState = n.type === "goal" || n.type === "intent"; const showState = n.type === "goal" || n.type === "intent";
const showPriority = (n.type === "goal" || n.type === "intent") && n.priority > 0; const showPriority = (n.type === "goal" || n.type === "intent") && n.priority > 0;
const isLive = n.type === "intent" && n.state === "running"; const isLive = !n.inherited && n.type === "intent" && n.state === "running";
return ( return (
<div <div
@@ -273,7 +274,11 @@ function ExploreNode({ data, selected }: NodeProps<ExploreRFNode>) {
{nodeSummary(n) || meta.label} {nodeSummary(n) || meta.label}
</div> </div>
<div className="flex items-center justify-between gap-2 px-3 pb-2.5"> <div className="flex items-center justify-between gap-2 px-3 pb-2.5">
{showState ? ( {n.inherited && n.source_task_id ? (
<Badge variant="outline" className="max-w-32 truncate" title={`继承自任务 #${n.source_task_id},只读`}>
来源 #{n.source_task_id}
</Badge>
) : showState ? (
<StatusBadge domain={n.type === "goal" ? "goal" : "intent"} value={n.state} dot className="px-1.5 py-0 text-[10px]" /> <StatusBadge domain={n.type === "goal" ? "goal" : "intent"} value={n.state} dot className="px-1.5 py-0 text-[10px]" />
) : ( ) : (
<span className="text-[10px] text-neutral-400 dark:text-neutral-500">{meta.label}</span> <span className="text-[10px] text-neutral-400 dark:text-neutral-500">{meta.label}</span>
@@ -334,6 +339,11 @@ function NodeDetailSheet({
<SheetTitle className="leading-tight">{meta.label}</SheetTitle> <SheetTitle className="leading-tight">{meta.label}</SheetTitle>
<span className="text-muted-foreground font-mono text-xs">{node.id}</span> <span className="text-muted-foreground font-mono text-xs">{node.id}</span>
</div> </div>
{node.inherited && node.source_task_id && (
<Badge variant="outline" className="ml-auto shrink-0">
来源任务 #{node.source_task_id} · 只读
</Badge>
)}
</div> </div>
</SheetHeader> </SheetHeader>
@@ -355,6 +365,11 @@ function NodeDetailSheet({
<DetailRow label="来源"> <DetailRow label="来源">
<span className="font-mono text-xs">{node.origin}</span> <span className="font-mono text-xs">{node.origin}</span>
</DetailRow> </DetailRow>
{node.inherited && node.source_task_id && (
<DetailRow label="来源任务">
<span className="font-mono text-xs">#{node.source_task_id}(继承,只读)</span>
</DetailRow>
)}
<DetailRow label="时间">{new Date(node.ts).toLocaleString("zh-CN")}</DetailRow> <DetailRow label="时间">{new Date(node.ts).toLocaleString("zh-CN")}</DetailRow>
</section> </section>
@@ -398,7 +413,7 @@ function ExplorationGraphInner({
React.useEffect(() => { React.useEffect(() => {
setSelected((cur) => (cur ? (nodes.find((n) => n.id === cur.id) ?? cur) : cur)); setSelected((cur) => (cur ? (nodes.find((n) => n.id === cur.id) ?? cur) : cur));
const liveTargets = new Set( 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); const layout = computeLayout(nodes, edges);
setRfNodes((prev) => { setRfNodes((prev) => {
@@ -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<HTMLElement | null>;
}
function ProfileRoleBadge({ index, currentIndex }: { index: number; currentIndex: number }) {
if (index === currentIndex) return <Badge variant="default">当前</Badge>;
if (index < currentIndex) {
return (
<Badge variant="outline" title="当前游标之前的配置不会被自动故障转移选中">
已跳过
</Badge>
);
}
return <Badge variant="secondary">备用 {index - currentIndex}</Badge>;
}
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 (
<div className="flex flex-col gap-3">
<Combobox
items={itemIDs}
itemToStringValue={profileLabel}
multiple
value={value}
onValueChange={handleValueChange}
disabled={disabled}
>
<ComboboxChips>
<ComboboxValue>
{value.map((id) => (
<ComboboxChip key={id}>{profilesByID.get(id)?.name ?? `#${id}`}</ComboboxChip>
))}
</ComboboxValue>
<ComboboxChipsInput
id={inputId}
placeholder={profiles.length > 0 ? "搜索并添加 LLM 配置" : "暂无可用 LLM 配置"}
disabled={disabled ? true : profilesUnavailable}
/>
</ComboboxChips>
<ComboboxContent portalContainer={portalContainer}>
<ComboboxEmpty>没有匹配的 LLM 配置</ComboboxEmpty>
<ComboboxList>
{(id) => {
const profile = profilesByID.get(id);
return (
<ComboboxItem key={id} value={id} disabled={!profile}>
<div className="flex min-w-0 flex-1 flex-col">
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">
{profile?.name ?? `配置 #${id}`}
{profile?.is_default ? "(激活)" : ""}
</span>
</span>
{profile && <span className="truncate text-muted-foreground text-xs">{profile.model}</span>}
</div>
</ComboboxItem>
);
}}
</ComboboxList>
</ComboboxContent>
</Combobox>
{value.length === 0 ? (
<p className="text-muted-foreground text-xs">未指定配置时,任务跟随 Agent 或全局激活配置。</p>
) : (
<div className="flex flex-col divide-y rounded-lg border">
{value.map((id, index) => {
const profile = profilesByID.get(id);
return (
<div
key={id}
className="grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 px-2.5 py-2 sm:flex"
>
<span className="w-5 shrink-0 text-center text-muted-foreground text-xs tabular-nums">{index + 1}</span>
<div className="min-w-0 sm:flex-1">
<p className="truncate font-medium text-sm">{profile?.name ?? `配置 #${id}`}</p>
<div className="flex min-w-0 items-center gap-2">
<p className="truncate text-muted-foreground text-xs">{profile?.model ?? "配置已不可用"}</p>
</div>
</div>
<ProfileRoleBadge index={index} currentIndex={currentIndex} />
<div className="col-span-2 col-start-2 flex shrink-0 items-center gap-1 justify-self-end sm:col-auto sm:justify-self-auto">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="上移配置"
onClick={() => move(index, -1)}
disabled={disabled ? true : index === 0}
>
<ArrowUpIcon data-icon="inline-start" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="下移配置"
onClick={() => move(index, 1)}
disabled={disabled ? true : index === value.length - 1}
>
<ArrowDownIcon data-icon="inline-start" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="移除配置"
onClick={() => remove(id)}
disabled={disabled}
>
<XIcon data-icon="inline-start" />
</Button>
</div>
</div>
);
})}
</div>
)}
{onActiveProfileChange && value.length > 0 && (
<div className="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<span className="font-medium text-sm">当前配置</span>
<Select
value={activeProfileId && value.includes(activeProfileId) ? activeProfileId : value[0]}
onValueChange={onActiveProfileChange}
disabled={disabled}
>
<SelectTrigger size="sm" className="w-full sm:min-w-48 sm:max-w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{value.map((id) => {
const profile = profilesByID.get(id);
return (
<SelectItem key={id} value={id} disabled={!profile}>
{profile?.name ?? `配置 #${id}`}
</SelectItem>
);
})}
</SelectGroup>
</SelectContent>
</Select>
</div>
)}
</div>
);
}
+5 -2
View File
@@ -18,6 +18,7 @@ import {
import { toast } from "sonner"; import { toast } from "sonner";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { Markdown } from "@/components/markdown"; import { Markdown } from "@/components/markdown";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import type { Activity } from "@/lib/types"; import type { Activity } from "@/lib/types";
@@ -208,7 +209,7 @@ function InterceptCard({
}, [pendingId]); }, [pendingId]);
async function decide(decision: "allowed" | "denied") { async function decide(decision: "allowed" | "denied") {
if (!pendingId || deciding) return; if (step.inherited || !pendingId || deciding) return;
setDeciding(true); setDeciding(true);
try { try {
await api.interceptDecide(pendingId, decision); await api.interceptDecide(pendingId, decision);
@@ -246,7 +247,9 @@ function InterceptCard({
</div> </div>
</div> </div>
{decided ? ( {step.inherited ? (
<Badge variant="outline">历史记录 · 只读</Badge>
) : decided ? (
<span className={ <span className={
"shrink-0 rounded px-2 py-0.5 text-[11px] font-medium " + "shrink-0 rounded px-2 py-0.5 text-[11px] font-medium " +
(decided === "allowed" (decided === "allowed"
+4 -4
View File
@@ -4,7 +4,7 @@ import * as React from "react"
import { Checkbox as CheckboxPrimitive } from "radix-ui" import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { CheckIcon } from "lucide-react" import { CheckIcon, MinusIcon } from "lucide-react"
function Checkbox({ function Checkbox({
className, className,
@@ -21,10 +21,10 @@ function Checkbox({
> >
<CheckboxPrimitive.Indicator <CheckboxPrimitive.Indicator
data-slot="checkbox-indicator" data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5" className="group/checkbox-indicator grid place-content-center text-current transition-none [&>svg]:size-3.5"
> >
<CheckIcon <CheckIcon className="group-data-[state=indeterminate]/checkbox-indicator:hidden" />
/> <MinusIcon className="hidden group-data-[state=indeterminate]/checkbox-indicator:block" />
</CheckboxPrimitive.Indicator> </CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>
) )
+5 -2
View File
@@ -93,14 +93,17 @@ function ComboboxContent({
align = "start", align = "start",
alignOffset = 0, alignOffset = 0,
anchor, anchor,
portalContainer,
...props ...props
}: ComboboxPrimitive.Popup.Props & }: ComboboxPrimitive.Popup.Props &
Pick< Pick<
ComboboxPrimitive.Positioner.Props, ComboboxPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset" | "anchor" "side" | "align" | "sideOffset" | "alignOffset" | "anchor"
>) { > & {
portalContainer?: ComboboxPrimitive.Portal.Props["container"]
}) {
return ( return (
<ComboboxPrimitive.Portal> <ComboboxPrimitive.Portal container={portalContainer}>
<ComboboxPrimitive.Positioner <ComboboxPrimitive.Positioner
side={side} side={side}
sideOffset={sideOffset} sideOffset={sideOffset}
+54 -20
View File
@@ -6,7 +6,7 @@
import { MOCK } from "@/lib/mock/enabled"; import { MOCK } from "@/lib/mock/enabled";
import { mockHandle } from "@/lib/mock/handler"; import { mockHandle } from "@/lib/mock/handler";
import type { import type {
Task, Stats, Asset, AssetNode, Edge, Company, TaskNode, Finding, Severity, FindingStatus, Task, DeleteTaskOptions, DeleteTaskResult, Stats, Asset, AssetNode, Edge, Company, TaskNode, Finding, Severity, FindingStatus,
FindingsPage, FindingStats, FindingQuery, Activity, FindingsPage, FindingStats, FindingQuery, Activity,
Audit, TrafficResp, TrafficDetail, TrafficHost, Settings, LLMProfile, Agent, AgentDetail, PromptVar, Audit, TrafficResp, TrafficDetail, TrafficHost, Settings, LLMProfile, Agent, AgentDetail, PromptVar,
PromptVersion, MCPServer, MCPTool, SkillItem, TokenUsage, TokenTotal, DailyTokenBucket, PromptVersion, MCPServer, MCPTool, SkillItem, TokenUsage, TokenTotal, DailyTokenBucket,
@@ -41,7 +41,19 @@ async function http<T>(path: string, init?: RequestInit): Promise<T> {
} }
throw new Error("未授权"); 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; if (r.status === 204) return undefined as T;
return r.json(); return r.json();
} }
@@ -113,26 +125,45 @@ export const api = {
// ---- tasks ---- // ---- tasks ----
tasks: () => get<{ tasks: Task[]; active: string }>("/tasks").then((r) => ({ tasks: arr(r.tasks), active: r.active ?? "" })), 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<Task>("/tasks", { post<Task>("/tasks", {
description, description: input.description,
goal, goal: input.goal,
llm_profile_id: llmProfileId ?? null, llm_profile_ids: input.llmProfileIds ?? [],
timeout_seconds: timeoutSeconds ?? 0, source_task_ids: input.sourceTaskIds ?? [],
seed_first_intent: seedFirstIntent ?? false, timeout_seconds: input.timeoutSeconds ?? 0,
plan_heartbeat_seconds: planHeartbeatSeconds ?? 0, // 0 = 后端归一到默认 600(10min) seed_first_intent: input.seedFirstIntent ?? false,
plan_heartbeat_seconds: input.planHeartbeatSeconds ?? 0, // 0 = 后端归一到默认 600(10min)
}), }),
// 删除任务;opts 里的四个可选项默认不删:assets 关联资产、findings 发现漏洞、 updateTaskLLMProfiles: (id: string, llmProfileIds: number[], activeLLMProfileId?: number) =>
// traffic 测试流量(按任务 host 清)、files 测试过程写的文件(tasks/<id> 目录)。 put<{
deleteTask: ( id: string;
id: string, llm_profile_ids: number[];
opts?: { assets?: boolean; findings?: boolean; traffic?: boolean; files?: boolean }, active_llm_profile_id?: number;
) => llm_failover_state: string;
del<{ deleted: string; assets_deleted?: number; findings_deleted?: number; traffic_deleted?: number; files_deleted?: boolean }>( reopened_intents: number;
`/tasks/${id}`, switch_event?: Activity;
opts, }>(`/tasks/${id}/llm`, {
), llm_profile_ids: llmProfileIds,
active_llm_profile_id: activeLLMProfileId ?? null,
}),
deleteTask: (id: string, options: DeleteTaskOptions) =>
del<DeleteTaskResult>(`/tasks/${id}`, options),
controlTask: (id: string, action: "pause" | "resume") => post<{ id: string; paused: boolean }>(`/tasks/${id}/control`, { action }), 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 会重新认领、从头再跑。 // 重跑一条没跑成功的意图(blocked/exhausted/stopped):置回 open,worker 会重新认领、从头再跑。
rerunIntent: (taskId: string, intentId: string) => post<{ id: string; reopened: number }>(`/tasks/${taskId}/intents/${intentId}/rerun`), rerunIntent: (taskId: string, intentId: string) => post<{ id: string; reopened: number }>(`/tasks/${taskId}/intents/${intentId}/rerun`),
// 批量重跑本任务全部 blocked 意图(一次网络/LLM 断连导致多条 blocked 时一键全部重试)。 // 批量重跑本任务全部 blocked 意图(一次网络/LLM 断连导致多条 blocked 时一键全部重试)。
@@ -257,7 +288,10 @@ export const api = {
return get<FindingsPage>(`/exploration/findings?${p.toString()}`); return get<FindingsPage>(`/exploration/findings?${p.toString()}`);
}, },
findingStats: () => get<FindingStats>("/exploration/findings/stats"), findingStats: () => get<FindingStats>("/exploration/findings/stats"),
getFinding: (id: string) => get<Finding>(`/exploration/findings/${id}`), getFinding: (id: string, contextTaskId?: string) =>
get<Finding>(
`/exploration/findings/${id}${contextTaskId ? `?context_task=${encodeURIComponent(contextTaskId)}` : ""}`,
),
// 漏洞链路:该漏洞节点回溯到任务初始节点的子图(节点 + 关系)。 // 漏洞链路:该漏洞节点回溯到任务初始节点的子图(节点 + 关系)。
findingLineage: (id: string) => findingLineage: (id: string) =>
get<{ nodes: TaskNode[]; edges: Edge[] }>(`/exploration/findings/${id}/lineage`), get<{ nodes: TaskNode[]; edges: Edge[] }>(`/exploration/findings/${id}/lineage`),
+28 -6
View File
@@ -29,6 +29,10 @@ export const tasks: Task[] = [
paused: false, active: true, in_flight: 3, stalled: false, paused: false, active: true, in_flight: 3, stalled: false,
goals_total: 5, goals_met: 2, engine_mode: "exploring", goals_total: 5, goals_met: 2, engine_mode: "exploring",
llm_profile_id: 1, 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 }, 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, paused: false, active: false, in_flight: 2, stalled: false,
goals_total: 4, goals_met: 1, engine_mode: "exploring", goals_total: 4, goals_met: 1, engine_mode: "exploring",
llm_profile_id: 1, 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 }, 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, paused: true, active: false, in_flight: 0, stalled: false,
goals_total: 4, goals_met: 1, engine_mode: "paused", goals_total: 4, goals_met: 1, engine_mode: "paused",
llm_profile_id: 2, 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 }, 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, paused: false, active: false, in_flight: 0, stalled: false,
goals_total: 3, goals_met: 3, engine_mode: "idle", goals_total: 3, goals_met: 3, engine_mode: "idle",
llm_profile_id: 1, 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 }, 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 ────────────────────────────────────────────────────────────────── // ── 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 = { const base: Stats = {
assets: assets.length, assets: assets.length,
engine_mode: "exploring", engine_mode: "exploring",
llm_configured: true, llm_configured: true,
roe_enabled: 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; if (!t) return base;
return { return {
...base, ...base,
engine_mode: t.engine_mode ?? "idle", 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: { active_task: {
id: t.id, in_flight: t.in_flight, goals_total: t.goals_total, 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, goals_met: t.goals_met, engine_mode: t.engine_mode, paused: t.paused,
@@ -892,8 +914,8 @@ const llmTaskCounts: Record<string, number> = {};
for (const r of llmRecords) if (r.task_id) llmTaskCounts[r.task_id] = (llmTaskCounts[r.task_id] ?? 0) + 1; 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 const llmTasks: LLMTask[] = Object.entries(llmTaskCounts).map(([task_id, count]) => ({ task_id, count }));
export function llmRecordDetail(id: number) { export function llmRecordDetail(id: number, records = llmRecords) {
const item = llmRecords.find((r) => r.id === id) ?? llmRecords[0]; const item = records.find((r) => r.id === id) ?? records[0];
return { return {
...item, ...item,
request_body: JSON.stringify( request_body: JSON.stringify(
+162 -27
View File
@@ -2,10 +2,18 @@
// 未命中的一律返回安全默认([] / {} / {ok:true}),保证任何页面都不崩。 // 未命中的一律返回安全默认([] / {} / {ok:true}),保证任何页面都不崩。
// 只在 NEXT_PUBLIC_MOCK=1 时经由 api.ts 的 http() 短路进入这里。 // 只在 NEXT_PUBLIC_MOCK=1 时经由 api.ts 的 http() 短路进入这里。
import type { Task } from "../types";
import * as D from "./data"; import * as D from "./data";
const delay = (ms = 120) => new Promise((r) => setTimeout(r, ms)); 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<string, unknown> { function parseBody(body?: BodyInit | null): Record<string, unknown> {
if (typeof body !== "string") return {}; if (typeof body !== "string") return {};
try { 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 }; if (path === "/auth/change-password") return { ok: true };
// ── tasks ── // ── tasks ──
if (path === "/tasks" && m === "GET") return { tasks: D.tasks, active: D.ACTIVE_TASK }; if (path === "/tasks" && m === "GET") return { tasks: mockTasks, active: mockActiveTask };
if (path === "/tasks" && m === "POST") if (path === "/tasks" && m === "POST") {
return { ...D.tasks[0], id: "t-new", description: String(b.description ?? "新任务"), goal: String(b.goal ?? ""), status: "created" }; let suffix = 1;
if (seg[0] === "tasks" && seg.length === 2 && m === "DELETE") return { deleted: 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] === "control") return { id: seg[1], paused: b.action === "pause" };
if (seg[0] === "tasks" && seg[2] === "chat" && seg[3] === "stop") return { status: "stopped" }; 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; 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") ?? ""); if (path === "/workspace/read") return D.workspaceRead(q.get("path") ?? "");
// ── stats ── // ── stats ──
if (path === "/stats") return D.stats(task); if (path === "/stats") {
return D.stats(task, { tasks: mockTasks, findings: mockFindings, activeTask: mockActiveTask });
}
// ── assets ── // ── assets ──
if (path === "/assets/counts") return D.assetCounts; if (path === "/assets/counts") return D.assetCounts;
@@ -74,10 +178,10 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
// ── exploration ── // ── exploration ──
if (path === "/exploration/frontier") return D.frontier; if (path === "/exploration/frontier") return D.frontier;
if (path === "/exploration/findings/stats") { 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 用)。 // 「按任务」下拉:有漏洞的任务 + 描述 + 条数(mock 任务 id 是字符串,直接当 id 用)。
const taskMap = new Map<string, { description: string; count: number }>(); const taskMap = new Map<string, { description: string; count: number }>();
for (const f of D.findings) { for (const f of mockFindings) {
if (!f.task_id) continue; if (!f.task_id) continue;
const cur = taskMap.get(f.task_id) ?? { description: f.task_description ?? "", count: 0 }; const cur = taskMap.get(f.task_id) ?? { description: f.task_description ?? "", count: 0 };
cur.count++; 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 })); const tasks = Array.from(taskMap, ([id, v]) => ({ id, description: v.description, count: v.count }));
return { return {
total: D.findings.length, total: mockFindings.length,
pending: D.findings.filter((f) => f.status === "pending").length, pending: mockFindings.filter((f) => f.status === "pending").length,
critical: D.findings.filter((f) => f.severity === "critical").length, critical: mockFindings.filter((f) => f.severity === "critical").length,
high: D.findings.filter((f) => f.severity === "high").length, high: mockFindings.filter((f) => f.severity === "high").length,
medium: D.findings.filter((f) => f.severity === "medium").length, medium: mockFindings.filter((f) => f.severity === "medium").length,
low: D.findings.filter((f) => f.severity === "low").length, low: mockFindings.filter((f) => f.severity === "low").length,
vulnclasses, vulnclasses,
tasks, tasks,
}; };
@@ -102,7 +206,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
seg.length === 3 && seg.length === 3 &&
seg[2] !== "stats" 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 (!f) return {};
if (m === "PATCH") { if (m === "PATCH") {
if (typeof b.status === "string") f.status = b.status as typeof f.status; 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.name === "string") f.name = b.name;
if (typeof b.vulnclass === "string") f.vulnclass = b.vulnclass; 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") { if (path === "/exploration/findings") {
// finding_id=id:真后端用独立表行 id 作为状态/详情句柄,mock 里用自身 id 顶上。 // finding_id=id:真后端用独立表行 id 作为状态/详情句柄,mock 里用自身 id 顶上。
// report 仅详情接口返回,列表剥掉(与后端一致)。 // report 仅详情接口返回,列表剥掉(与后端一致)。
const withFid = (f: (typeof D.findings)[number]) => ({ const withFid = (f: (typeof mockFindings)[number]) => ({
...f, ...f,
report: undefined, report: undefined,
finding_id: f.id, 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)。 // 全局:带 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; 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 fSev = q.get("severity");
const fStatus = q.get("status"); const fStatus = q.get("status");
const fVuln = q.get("vulnclass"); 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 (path === "/exploration/intents") {
if (q.has("page")) { if (q.has("page")) {
const before = Number(q.get("before") ?? 0); 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; 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 { items: list.slice(0, limit), has_more: list.length > limit };
} }
return D.intents; 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/tokens") return { workers: D.tokenWorkers, total: D.tokenTotal };
if (path === "/exploration/graph") return D.explorationGraph; if (path === "/exploration/graph") return D.explorationGraph;
if (path === "/exploration/activity" && seg.length === 2) { if (path === "/exploration/activity" && seg.length === 2) {
const items = D.activityForTask(); const since = Number(q.get("since") ?? 0);
return { items, cursor: items.length ? items[items.length - 1].seq : 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) { if (seg[0] === "exploration" && seg[1] === "activity" && seg.length === 3) {
const a = D.activity.find((x) => x.seq === Number(seg[2])); 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 }; if (path === "/commands" && m === "GET") return { commands: D.commandRecords, total: D.commandRecords.length };
// ── LLM ── // ── 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" && m === "DELETE") return { deleted: 0 };
if (path === "/llm/records/tasks") return { tasks: D.llmTasks }; if (path === "/llm/records/tasks") {
if (seg[0] === "llm" && seg[1] === "records" && seg.length === 3 && m === "GET") return D.llmRecordDetail(Number(seg[2])); const counts = new Map<string, number>();
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 === "GET") return D.llmConfig;
if (path === "/llm" && m === "POST") return { ok: true }; 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") }; if (path === "/llm/test") return { ok: true, latency_ms: 128, model: String(b.model ?? "claude-opus-4-8") };
+1
View File
@@ -47,6 +47,7 @@ interface StatusMeta {
const intent: Record<string, StatusMeta> = { const intent: Record<string, StatusMeta> = {
open: { label: "待领", tone: "slate" }, open: { label: "待领", tone: "slate" },
running: { label: "执行中", tone: "blue" }, running: { label: "执行中", tone: "blue" },
paused: { label: "已暂停", tone: "amber" },
done: { label: "已完成", tone: "green" }, done: { label: "已完成", tone: "green" },
blocked: { label: "被拦截", tone: "red" }, blocked: { label: "被拦截", tone: "red" },
exhausted: { label: "已穷尽", tone: "violet" }, exhausted: { label: "已穷尽", tone: "violet" },
+57 -2
View File
@@ -15,6 +15,7 @@ export interface Task {
completed_unix?: number; // completed_at as unix seconds (0/undef if unfinished) 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) last_activity_unix?: number; // unix seconds of the last activity (0/undef if none)
paused?: boolean; paused?: boolean;
queued?: boolean;
active?: boolean; active?: boolean;
in_flight?: number; in_flight?: number;
last_activity?: string; last_activity?: string;
@@ -24,6 +25,30 @@ export interface Task {
engine_mode?: EngineMode; engine_mode?: EngineMode;
tokens?: TokenTotal; // whole-task token consumption tokens?: TokenTotal; // whole-task token consumption
llm_profile_id?: number; // LLM profile used; absent = default profile 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) ---- // ---- Asset graph (global, shared across tasks) ----
@@ -171,6 +196,8 @@ export interface CoverageAssetRef {
kind: string; kind: string;
state: string; state: string;
summary: string; summary: string;
source_task_id?: string;
inherited?: boolean;
} }
export interface CoverageAssetRefs { export interface CoverageAssetRefs {
intents: CoverageAssetRef[]; intents: CoverageAssetRef[];
@@ -234,6 +261,8 @@ export interface TaskNode {
state: string; // GoalState | IntentState | FindingState | HintState state: string; // GoalState | IntentState | FindingState | HintState
origin: string; origin: string;
ts: string; ts: string;
source_task_id?: string;
inherited?: boolean;
} }
// ---- Findings ---- // ---- Findings ----
@@ -271,6 +300,8 @@ export interface Finding {
param_id?: string; param_id?: string;
task_id?: string; task_id?: string;
task_description?: string; task_description?: string;
source_task_id?: string;
inherited?: boolean;
assets?: FindingAsset[]; assets?: FindingAsset[];
ts: string; ts: string;
} }
@@ -325,6 +356,8 @@ export type ActivityKind =
| "intent" // LLM-generated exploration objective leading a worker session (UI-synthesized) | "intent" // LLM-generated exploration objective leading a worker session (UI-synthesized)
| "round" // planner round boundary marker (engine-emitted) | "round" // planner round boundary marker (engine-emitted)
| "usage" // live cumulative token usage (per model turn); not rendered | "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 | "intercept_request"; // user-approval request from the intercept layer
// ChatAttachment 是一次上传的文件:path 相对该会话/任务工作目录(即 agent 的 CWD)。 // ChatAttachment 是一次上传的文件:path 相对该会话/任务工作目录(即 agent 的 CWD)。
@@ -346,6 +379,11 @@ export interface Activity {
is_error?: boolean; is_error?: boolean;
summary: string; summary: string;
detail?: string; detail?: string;
metadata?: {
llm_transition?: LLMTransition;
};
source_task_id?: string;
inherited?: boolean;
// token usage (present only on kind='result') // token usage (present only on kind='result')
input_tokens?: number; input_tokens?: number;
output_tokens?: number; output_tokens?: number;
@@ -353,6 +391,20 @@ export interface Activity {
cache_write_tokens?: number; 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) ---- // ---- Agent triggers (P3 调度,仅自定义 agent) ----
export interface AgentTrigger { export interface AgentTrigger {
id: number; id: number;
@@ -394,8 +446,8 @@ export interface LogLine {
text: string; text: string;
} }
export type SessionRole = "mainagent" | "planner" | "worker"; export type SessionRole = "mainagent" | "planner" | "worker" | "system";
export type SessionStatus = "running" | "done" | "blocked" | "exhausted" | "pending" | "stopped"; export type SessionStatus = "running" | "paused" | "done" | "blocked" | "exhausted" | "pending" | "stopped";
// Daily token aggregate bucket (GET /api/tokens/daily). // Daily token aggregate bucket (GET /api/tokens/daily).
export interface DailyTokenBucket { export interface DailyTokenBucket {
@@ -431,6 +483,8 @@ export interface Session {
live: boolean; live: boolean;
last_activity: string; last_activity: string;
intent_id?: string; intent_id?: string;
source_task_id?: string;
inherited?: boolean;
} }
// ---- Security ---- // ---- Security ----
@@ -669,6 +723,7 @@ export interface Stats {
llm_configured: boolean; llm_configured: boolean;
roe_enabled: boolean; roe_enabled: boolean;
findings_confirmed: number; findings_confirmed: number;
active_task?: Partial<Task>;
} }
// ---- Intercept Rules ---- // ---- Intercept Rules ----