feat(agent): 诊断 run 取消/终止原因,取代笼统的中断提示

- 新增 AbortCause(cancelcause.go):各取消点用 WithCancelCause/WithTimeoutCause
  附上原因(暂停/删除/kill_work/目标达成/drain 兜底/聊天停止/进程关闭/硬超时…),
  captureRun 用 context.Cause 还原出到底是谁、为何取消。
- 新增 terminalreason.go:把终端状态转成可读的一行摘要 + 多行详情(取消原因、
  终止 reason、轮数、卡住的工具),替换原来固定那句猜测式提示。
- engine/worker/planner/capture 及 conversations/orchestration/server 接入;
  cmd/artex/main.go、前端 llm/main-content 相应调整。
- 配套测试 terminalreason_test.go / engine_cancelcause_test.go。
This commit is contained in:
Autumn-27 committed 2026-08-19 09:20:10 -04:00
1 parent 8e9b126972
commit a6bd18050c
16 files changed
+624 -60

No files matched your search

+116
View File
@@ -0,0 +1,116 @@
package agent
import (
"context"
"errors"
"fmt"
)
// AbortCause names WHY a planner/worker run's context was cancelled.
//
// Background: when a run's ctx is cancelled mid-flight the harness finishes with
// ReasonAbortedStreaming / ReasonAbortedTools and Terminal.Err = context.Canceled.
// ARTEX cancels that ctx from a dozen different places (pause, delete, kill_work,
// goal-met, settle drain backstop, chat stop, process shutdown, hard run timeout,
// …) but by the time it reaches captureRun they all collapse into one bare
// context.Canceled — so the trace could only print a guess-list, not a diagnosis.
//
// Fix: every cancel site uses context.WithCancelCause / WithTimeoutCause and
// attaches one of the causes below; captureRun recovers it via context.Cause and
// prints exactly who cancelled the run, why, and what happens to the intent.
//
// When you add a new cancel site, add its cause HERE — a site that cancels
// without one shows up in the trace as "canceled_no_cause", by design.
type AbortCause struct {
Code string // stable machine-readable id (logs / future filtering)
Short string // 一句话,进折叠摘要行
Text string // 完整说明:谁取消的、为什么、这条意图接下来会怎样
}
func (c *AbortCause) Error() string { return c.Text }
func cause(code, short, text string) *AbortCause {
return &AbortCause{Code: code, Short: short, Text: text}
}
// Causef builds an ad-hoc cause with runtime detail (e.g. which intent, which
// deadline). Use the predeclared vars below when there is nothing dynamic to add.
func Causef(code, short, format string, args ...any) *AbortCause {
return &AbortCause{Code: code, Short: short, Text: fmt.Sprintf(format, args...)}
}
// The full enumeration of cancel sites, grouped by which context gets cancelled.
var (
// ---- 任务级 exec ctx(Engine.cancelExec:取消该任务在跑的 planner + 全部 worker)----
AbortPausedByUser = cause("paused_by_user", "用户暂停了任务",
"用户暂停了任务(前端「暂停」按钮 / POST /api/tasks/{id}/state action=pause)。本次运行被主动取消,该意图已退回 frontier(open),恢复任务后会被重新领取、从头重跑")
AbortPausedByOrchestrator = cause("paused_by_orchestrator", "编排 agent 调用 pause_task 暂停了任务",
"编排 agent 调用了 pause_task 工具暂停本任务。本次运行被主动取消,该意图已退回 frontier(open),恢复后重跑")
AbortTaskDeleted = cause("task_deleted", "任务被删除",
"任务被删除(DELETE /api/tasks/{id})——删除前先取消在跑的 planner/worker。本次运行的结果不会再被使用")
AbortPausedOnReload = cause("paused_on_reload", "后端启动时恢复了「已暂停」状态",
"后端启动时按库里持久化的「已暂停」状态恢复暂停。本次运行被取消(正常情况下此时并没有在跑的 run)")
AbortGoalMet = cause("goal_met", "规划者判定目标已达成,任务落终态 done",
"规划者判定任务目标已达成、任务落终态 done,随即取消所有在跑的 worker——它们手头的意图跑完也没意义了。该意图标记为 stopped,不是失败")
AbortSettleDrainTimeout = cause("settle_drain_timeout", "任务级超时收尾:drain 宽限用尽后强制取消",
"任务级超时收尾的硬兜底:到达任务 timeout 后等在跑 worker 优雅收尾,超过 90s drain 宽限仍未结束 → 强制取消。该意图标记为 exhausted,收尾阶段通常已把事实/资产写回")
// ---- 单个 work 的 ctx(Engine.workCancel[intentID])----
AbortKilledByPlanner = cause("killed_by_planner", "规划者用 kill_work 终止了这条意图",
"规划者调用 kill_work 主动终止了这条意图(判定方向跑偏/无意义,止损)。该意图标记为 stopped,不会被自动重领")
AbortWorkFinished = cause("work_finished", "work 已正常结束,引擎释放其 context",
"work 已正常结束,引擎在 unregisterWork 里释放其 context 资源。这不是中断——若它出现在中断消息里,说明取消与收场事件存在竞态")
AbortPausedRaceGuard = cause("paused_race_guard", "任务处于暂停态,引擎拒绝启动新 run",
"任务处于暂停态时请求执行上下文,引擎直接返回一个已取消的 context(守卫 claim→Execute 的竞态,避免暂停后仍启动新 run)。该意图会退回 frontier")
// ---- 对话 / 主 Agent 的单轮 ctx(Server.chatCancel[busyKey])----
AbortChatStoppedByUser = cause("chat_stopped_by_user", "用户点了「停止」中止本轮对话",
"用户点了「停止」,主动中止本轮对话运行(POST .../chat/stop 或 .../conversations/{id}/stop)。已产生的步骤都保留,可以直接发下一条消息")
AbortChatTurnFinished = cause("chat_turn_finished", "本轮对话已正常结束,服务端释放其 context",
"本轮对话已正常结束,服务端在 defer 里释放其 context 资源。这不是中断——若它出现在中断消息里,说明取消与收场事件存在竞态")
// ---- 进程级 ----
AbortShutdown = cause("shutdown", "后端进程正在关停(重启 / 更新 / Ctrl-C)",
"后端进程收到 SIGINT/SIGTERM 正在关停(重启 / 更新 / 手动 Ctrl-C)。所有在跑的 planner/worker 一并取消;重启后残留的 running 意图会被重置为 open 重跑")
// ---- 单次 run 级(worker.Execute / planner.Plan 的硬兜底)----
AbortRunHardTimeout = cause("run_hard_timeout", "单次运行的硬超时兜底触发:某一步卡死了",
"单次运行的硬超时兜底被触发:正常情况下软墙钟预算(MaxDuration)会在回合边界优雅收尾,只有当某一步(模型请求或某个工具)自身卡死、久久不返回、导致回合边界检查根本跑不到时,才会走到「软预算 + 90s 宽限」的硬 cancel。请重点看中断前最后一个 tool_use——多半是它挂住了")
)
// AbortReason resolves the cause attached to a cancelled run context. ok=false
// means the ctx carries no cause at all (nothing was cancelled).
func AbortReason(ctx context.Context) (code, short, text string, ok bool) {
c := context.Cause(ctx)
if c == nil {
return "", "", "", false
}
if ac, ok := errors.AsType[*AbortCause](c); ok {
return ac.Code, ac.Short, ac.Text, true
}
switch {
case errors.Is(c, context.DeadlineExceeded):
return "deadline_exceeded", "上游 context 到达 deadline(未挂具名原因)",
"上游 context 到达 deadline,但设置方没有用 WithTimeoutCause 挂具名原因: " + c.Error(), true
case errors.Is(c, context.Canceled):
// a plain Canceled means some cancel site still lacks a cause — say so
// plainly instead of inventing one.
return "canceled_no_cause", "取消方未挂具名原因(漏网的取消点)",
"上游 context 被取消,但取消方没有用 context.WithCancelCause 挂上具名原因。" +
"这属于漏网的取消点——请在 agent/cancelcause.go 补一条枚举并接到该取消处", true
}
return "other", firstLine(c.Error(), 80), c.Error(), true
}
+20 -21
View File
@@ -3,6 +3,8 @@ package agent
import ( import (
"context" "context"
"strings" "strings"
"time"
"unicode/utf8"
"github.com/Autumn-27/norma/agentcore" "github.com/Autumn-27/norma/agentcore"
"github.com/Autumn-27/norma/harness" "github.com/Autumn-27/norma/harness"
@@ -64,13 +66,18 @@ func captureRunSession(ctx context.Context, s *agentcore.Session, input string,
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()}
var finalText string var finalText string
var rerr error var rerr error
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 = manual stop, not a failure if ctx.Err() != nil { // ctx cancelled = stopped by the engine, not a failure
rec(db.Activity{Kind: "result", Summary: "(已手动停止本次运行)"}) sum, detail := terminalText(ctx, &harness.Terminal{Reason: reason, Err: ctx.Err()}, lastTool)
rec(db.Activity{Kind: "result", Summary: firstLine(sum, 400), Detail: detail})
return finalText, reason, ctx.Err() return finalText, reason, ctx.Err()
} }
rec(db.Activity{Kind: "result", IsError: true, Summary: "执行出错: " + err.Error(), Detail: err.Error()}) rec(db.Activity{Kind: "result", IsError: true, Summary: "执行出错: " + err.Error(), Detail: err.Error()})
@@ -84,6 +91,7 @@ func captureRunSession(ctx context.Context, s *agentcore.Session, input string,
flush() flush()
toolNames[ev.ToolUse.ID] = ev.ToolUse.Name toolNames[ev.ToolUse.ID] = ev.ToolUse.Name
in := string(ev.ToolUse.Input) in := string(ev.ToolUse.Input)
lastTool.start(ev.ToolUse.ID, ev.ToolUse.Name, in)
rec(db.Activity{Kind: "tool_use", Tool: ev.ToolUse.Name, ToolUseID: ev.ToolUse.ID, rec(db.Activity{Kind: "tool_use", Tool: ev.ToolUse.Name, ToolUseID: ev.ToolUse.ID,
Summary: ev.ToolUse.Name + " " + firstLine(in, 200), Detail: in}) Summary: ev.ToolUse.Name + " " + firstLine(in, 200), Detail: in})
case harness.KindToolResult: case harness.KindToolResult:
@@ -92,6 +100,7 @@ func captureRunSession(ctx context.Context, s *agentcore.Session, input string,
} }
flush() flush()
out := blocksText(ev.ToolResult.Content) out := blocksText(ev.ToolResult.Content)
lastTool.done(ev.ToolResult.ToolUseID)
rec(db.Activity{Kind: "tool_result", Tool: toolNames[ev.ToolResult.ToolUseID], ToolUseID: ev.ToolResult.ToolUseID, rec(db.Activity{Kind: "tool_result", Tool: toolNames[ev.ToolResult.ToolUseID], ToolUseID: ev.ToolResult.ToolUseID,
IsError: ev.ToolResult.IsError, Summary: firstLine(out, 200), Detail: out}) IsError: ev.ToolResult.IsError, Summary: firstLine(out, 200), Detail: out})
case harness.KindText: case harness.KindText:
@@ -120,25 +129,13 @@ func captureRunSession(ctx context.Context, s *agentcore.Session, input string,
tkind = "" tkind = ""
} }
flush() // flush any trailing thinking / non-final text flush() // flush any trailing thinking / non-final text
sum := ev.Terminal.Text sum, detail := ev.Terminal.Text, ev.Terminal.Text
if sum == "" { if sum == "" {
switch ev.Terminal.Reason { sum, detail = terminalText(ctx, ev.Terminal, lastTool)
case harness.ReasonAbortedTools, harness.ReasonAbortedStreaming:
// interruption (task paused / worker killed / backend restart),
// not a real completion — the intent is re-queued (open) or
// stopped, so the session icon reflects that, not this run.
sum = "(运行被中断:任务暂停 / 终止 / 重启所致;未完成)"
case harness.ReasonTimeout, harness.ReasonMaxTurns:
// budget hit → settlement ran (facts/assets were written back);
// only the trailing text summary is missing, not a failure.
sum = "(达运行预算上限,已收尾写回事实;本次无文字总结,终态: " + string(ev.Terminal.Reason) + ")"
default:
sum = "(无总结,终态: " + string(ev.Terminal.Reason) + ")"
}
} }
u := ev.Terminal.Usage // cumulative token usage for this session u := ev.Terminal.Usage // cumulative token usage for this session
rec(db.Activity{Kind: "result", IsError: ev.Terminal.Err != nil, rec(db.Activity{Kind: "result", IsError: ev.Terminal.Err != nil,
Summary: firstLine(sum, 400), Detail: sum, Summary: firstLine(sum, 400), Detail: detail,
InputTokens: &u.InputTokens, OutputTokens: &u.OutputTokens, InputTokens: &u.InputTokens, OutputTokens: &u.OutputTokens,
CacheReadTokens: &u.CacheReadTokens, CacheWriteTokens: &u.CacheWriteTokens}) CacheReadTokens: &u.CacheReadTokens, CacheWriteTokens: &u.CacheWriteTokens})
if ev.Terminal.Err != nil { if ev.Terminal.Err != nil {
@@ -166,13 +163,15 @@ func blocksText(blocks []llm.ContentBlock) string {
} }
// firstLine returns a single-line, length-capped preview for the summary column. // firstLine returns a single-line, length-capped preview for the summary column.
// max counts RUNES, not bytes: these previews are mostly 中文, and byte-slicing
// used to cut a multi-byte rune in half and render as "恢�…".
func firstLine(s string, max int) string { func firstLine(s string, max int) string {
s = strings.TrimSpace(s) s = strings.TrimSpace(s)
if i := strings.IndexByte(s, '\n'); i >= 0 { if before, _, found := strings.Cut(s, "\n"); found {
s = s[:i] s = before
} }
if len(s) > max { if utf8.RuneCountInString(s) > max {
s = s[:max] + "…" s = string([]rune(s)[:max]) + "…"
} }
return s return s
} }
+1 -1
View File
@@ -334,7 +334,7 @@ func (p *Planner) Plan(ctx context.Context, taskID int64, as *db.AssetStore, ts
runCtx := ctx runCtx := ctx
if maxDur > 0 { if maxDur > 0 {
var cancel context.CancelFunc var cancel context.CancelFunc
runCtx, cancel = context.WithTimeout(ctx, maxDur+settleHardGrace) runCtx, cancel = context.WithTimeoutCause(ctx, maxDur+settleHardGrace, AbortRunHardTimeout)
defer cancel() defer cancel()
} }
_, _, err = captureRun(runCtx, opts, input, _, _, err = captureRun(runCtx, opts, input,
+160
View File
@@ -0,0 +1,160 @@
package agent
import (
"context"
"fmt"
"strings"
"time"
"github.com/Autumn-27/norma/harness"
)
// This file turns a run's terminal state into something a human can act on.
//
// Before: a cancelled run produced one fixed sentence — "(运行被中断:任务暂停 /
// 终止 / 重启所致;未完成)" — a guess-list, not a diagnosis. The information to do
// better was already there (the cancel cause, the terminal reason, the turn
// count, the tool that never returned); it was just being thrown away.
//
// terminalText produces a self-contained ONE-LINE summary (the transcript's
// collapsed row, cut at the first newline by firstLine) plus a multi-line detail
// block rendered in full when the row is expanded.
// runTrace records which tool call is in flight, so an aborted / timed-out run can
// name the step it died on — the single most useful fact when a run hangs.
type runTrace struct {
startedAt time.Time
id string // tool_use id of the most recent call
name string // its tool name
input string // its raw input JSON
at time.Time
pending bool // true = no tool_result arrived for it yet
}
func (t *runTrace) start(id, name, input string) {
t.id, t.name, t.input, t.at, t.pending = id, name, input, time.Now(), true
}
func (t *runTrace) done(id string) {
if id == t.id {
t.pending = false
}
}
// reasonHint explains each harness terminal reason in plain 中文: what it means,
// whether results were still written back, and what the engine does next. Covers
// every TerminalReason the harness can emit.
var reasonHint = map[harness.TerminalReason]string{
harness.ReasonCompleted: "模型主动结束了本轮(正常收场),但没有留下文字总结。事实/资产以工具调用为准,看上面的 tool_use 记录",
harness.ReasonMaxTurns: "撞到步数上限(MaxTurns):SDK 已自动跑了一轮收尾(settlement)把事实/资产写回,只是缺一段文字总结。" +
"意图标记为 exhausted——表示「这个方向试过但没做完」,规划者会据此换角度,不是失败",
harness.ReasonTimeout: "撞到单次运行的软墙钟预算(MaxDuration):SDK 在回合边界优雅收尾并写回事实/资产,只是缺文字总结。" +
"意图标记为 exhausted,不是失败",
harness.ReasonModelError: "模型/API 调用失败(网络、鉴权、限流、供应商 5xx 等)。引擎会退避后自动重跑几次;重试用尽仍失败则意图标记为 blocked",
harness.ReasonBlockingLimit: "上下文长度撞硬上限,请求在发出前就被拦下。通常是单次运行塞进了过多工具输出——考虑收窄意图粒度或压缩工具返回",
harness.ReasonPromptTooLong: "提示词过长且压缩(compaction)重试已用尽,无法继续。同样指向单次运行累积的上下文过大",
harness.ReasonImageError: "多模态内容不被当前模型支持(截图/图片块)。换支持视觉的模型,或让工具别回图",
harness.ReasonStopHookPrevented: "Stop 钩子阻止了本轮结束(守卫判定还不能收场),且随后没能继续下去。" +
"检查任务的 Guard 规则是否过严",
harness.ReasonHookStopped: "某个工具/钩子主动叫停了继续执行(守卫拦截,如越界目标、禁用命令)。看上面最后一条 tool_result 里的拦截说明",
harness.ReasonAbortedStreaming: "运行在【模型输出流式生成阶段】被取消",
harness.ReasonAbortedTools: "运行在【工具执行阶段】被取消",
}
// terminalText renders a terminal event with no final text into (summary, detail).
// summary is one self-contained line; detail is the expandable breakdown.
func terminalText(ctx context.Context, term *harness.Terminal, tr *runTrace) (string, string) {
reason := term.Reason
aborted := reason == harness.ReasonAbortedStreaming || reason == harness.ReasonAbortedTools
// ---- one-line summary: cause first (that's what the reader is after), then
// just enough run shape to judge it without expanding. ----
var sum string
switch {
case aborted:
_, short, _, ok := AbortReason(ctx)
if !ok {
short = "取不到取消原因(run 的 context 上没有挂 cause)"
}
stage := "工具执行阶段"
if reason == harness.ReasonAbortedStreaming {
stage = "模型输出阶段"
}
sum = "(运行被中断:" + short + ";停在" + stage + progressSuffix(term, tr) + ",未完成)"
case reason == harness.ReasonMaxTurns || reason == harness.ReasonTimeout:
sum = "(达运行预算上限(" + string(reason) + "),已收尾写回事实" + progressSuffix(term, tr) + ";本次无文字总结)"
default:
hint := reasonHint[reason]
if hint == "" {
hint = "未知终态(harness 新增了终态但这里还没登记,请补 reasonHint)"
}
sum = "(无文字总结,终态 " + string(reason) + ":" + firstLine(hint, 80) + ")"
}
// ---- expandable detail ----
var b strings.Builder
b.WriteString(sum)
b.WriteString("\n\n")
fmt.Fprintf(&b, "- **终态**: `%s` — %s\n", reason, reasonHint[reason])
if aborted {
code, _, why, ok := AbortReason(ctx)
if ok {
fmt.Fprintf(&b, "- **中断原因** (`%s`): %s\n", code, why)
} else {
b.WriteString("- **中断原因**: 取不到——取消方没有用 context.WithCancelCause 挂上具名原因。" +
"这属于漏网的取消点,请补 agent/cancelcause.go 里的枚举\n")
}
}
if term.Err != nil {
fmt.Fprintf(&b, "- **底层错误**: `%v`\n", term.Err)
}
if term.Turns > 0 {
fmt.Fprintf(&b, "- **已执行**: %d 轮模型回合\n", term.Turns)
}
if !tr.startedAt.IsZero() {
fmt.Fprintf(&b, "- **本次运行耗时**: %s\n", roundDur(time.Since(tr.startedAt)))
}
if u := term.Usage; u.InputTokens+u.OutputTokens+u.CacheReadTokens+u.CacheWriteTokens > 0 {
fmt.Fprintf(&b, "- **累计 token**: 输入 %d / 输出 %d / 缓存读 %d / 缓存写 %d\n",
u.InputTokens, u.OutputTokens, u.CacheReadTokens, u.CacheWriteTokens)
}
if tr.name != "" {
if tr.pending {
fmt.Fprintf(&b, "- **中断时正在执行的工具**: `%s`(已跑 %s,**未返回结果**) — 若是硬超时/长时间无进展,问题多半就在这一步\n ```\n %s\n ```\n",
tr.name, roundDur(time.Since(tr.at)), firstLine(tr.input, 300))
} else {
fmt.Fprintf(&b, "- **中断前最后一个工具**: `%s`(已正常返回)\n", tr.name)
}
} else {
b.WriteString("- **工具调用**: 本次运行还没发出任何工具调用就结束了\n")
}
return sum, b.String()
}
// progressSuffix adds "已跑 N 轮 / 12m30s" to a summary line — how far the run got
// before it died, which decides whether an abort cost anything worth re-running.
func progressSuffix(term *harness.Terminal, tr *runTrace) string {
var parts []string
if term.Turns > 0 {
parts = append(parts, fmt.Sprintf("%d 轮", term.Turns))
}
if !tr.startedAt.IsZero() {
parts = append(parts, roundDur(time.Since(tr.startedAt)))
}
if len(parts) == 0 {
return ""
}
return ",已跑 " + strings.Join(parts, " / ")
}
// roundDur formats a duration at a readable granularity (no 3.400213s noise).
func roundDur(d time.Duration) string {
switch {
case d < time.Minute:
return d.Round(100 * time.Millisecond).String()
case d < time.Hour:
return d.Round(time.Second).String()
default:
return d.Round(time.Minute).String()
}
}
+172
View File
@@ -0,0 +1,172 @@
package agent
import (
"context"
"errors"
"strings"
"testing"
"time"
"unicode/utf8"
"github.com/Autumn-27/norma/harness"
"github.com/Autumn-27/norma/llm"
)
// every TerminalReason the harness can emit must have an explanation — otherwise
// a run silently falls back to "未知终态", which is the bug this file exists to fix.
func TestReasonHintCoversEveryTerminalReason(t *testing.T) {
all := []harness.TerminalReason{
harness.ReasonCompleted, harness.ReasonBlockingLimit, harness.ReasonImageError,
harness.ReasonModelError, harness.ReasonAbortedStreaming, harness.ReasonAbortedTools,
harness.ReasonPromptTooLong, harness.ReasonStopHookPrevented, harness.ReasonHookStopped,
harness.ReasonMaxTurns, harness.ReasonTimeout,
}
for _, r := range all {
if strings.TrimSpace(reasonHint[r]) == "" {
t.Errorf("terminal reason %q 没有中文说明", r)
}
}
}
// the cause attached at cancel time must survive the ctx chain the engine builds:
// task exec ctx -> per-work child -> value ctx -> the run's hard-timeout backstop.
func TestAbortCausePropagatesThroughRunContextChain(t *testing.T) {
type key struct{}
exec, cancelExec := context.WithCancelCause(context.Background())
work, cancelWork := context.WithCancelCause(exec)
defer cancelWork(nil)
valued := context.WithValue(work, key{}, "task-1")
run, cancelRun := context.WithTimeoutCause(valued, time.Hour, AbortRunHardTimeout)
defer cancelRun()
cancelExec(AbortPausedByUser)
code, _, text, ok := AbortReason(run)
if !ok || code != "paused_by_user" {
t.Fatalf("code=%q ok=%v,期望 paused_by_user", code, ok)
}
if !strings.Contains(text, "退回 frontier") {
t.Errorf("原因文案没传下来: %q", text)
}
}
func TestAbortReasonFallbacks(t *testing.T) {
if _, _, _, ok := AbortReason(context.Background()); ok {
t.Error("未取消的 ctx 不应报出中断原因")
}
// a cancel site that forgot to attach a cause must be called out, not guessed at
plain, cancel := context.WithCancel(context.Background())
cancel()
code, _, _, ok := AbortReason(plain)
if !ok || code != "canceled_no_cause" {
t.Errorf("code=%q ok=%v,期望 canceled_no_cause", code, ok)
}
timed, cancelT := context.WithTimeout(context.Background(), time.Nanosecond)
defer cancelT()
<-timed.Done()
if code, _, _, _ := AbortReason(timed); code != "deadline_exceeded" {
t.Errorf("code=%q,期望 deadline_exceeded", code)
}
}
// an aborted run's one-line summary must name the actual cause, and the detail
// must pin down the tool that never came back.
func TestTerminalTextAbortedNamesCauseAndHangingTool(t *testing.T) {
ctx, cancel := context.WithCancelCause(context.Background())
cancel(AbortKilledByPlanner)
tr := &runTrace{startedAt: time.Now().Add(-90 * time.Second)}
tr.start("tu_1", "Bash", `{"command":"nmap -p- 10.0.0.1"}`)
term := &harness.Terminal{
Reason: harness.ReasonAbortedTools,
Err: context.Canceled,
Turns: 7,
Usage: llm.Usage{InputTokens: 1200, OutputTokens: 340},
}
sum, detail := terminalText(ctx, term, tr)
if !strings.Contains(sum, "kill_work") {
t.Errorf("摘要没点出取消方: %q", sum)
}
if strings.Contains(sum, "\n") {
t.Errorf("摘要必须是单行(前端按首行截断): %q", sum)
}
for _, want := range []string{"killed_by_planner", "aborted_tools", "7 轮", "Bash", "未返回结果", "1200"} {
if !strings.Contains(detail, want) {
t.Errorf("详情缺少 %q:\n%s", want, detail)
}
}
}
// a tool that DID return must not be reported as the hang site.
func TestTerminalTextCompletedToolNotBlamed(t *testing.T) {
ctx, cancel := context.WithCancelCause(context.Background())
cancel(AbortShutdown)
tr := &runTrace{startedAt: time.Now()}
tr.start("tu_1", "Read", `{"path":"/etc/hosts"}`)
tr.done("tu_1")
_, detail := terminalText(ctx, &harness.Terminal{Reason: harness.ReasonAbortedStreaming}, tr)
if strings.Contains(detail, "未返回结果") {
t.Errorf("已返回的工具被误报为卡住:\n%s", detail)
}
if !strings.Contains(detail, "已正常返回") {
t.Errorf("详情没记录最后一个工具:\n%s", detail)
}
}
// non-abort terminals keep their own explanation instead of the interruption text.
func TestTerminalTextNonAbortReasons(t *testing.T) {
ctx := context.Background()
tr := &runTrace{startedAt: time.Now()}
sum, _ := terminalText(ctx, &harness.Terminal{Reason: harness.ReasonMaxTurns}, tr)
if !strings.Contains(sum, "达运行预算上限") || strings.Contains(sum, "中断") {
t.Errorf("max_turns 摘要不对: %q", sum)
}
sum, detail := terminalText(ctx, &harness.Terminal{
Reason: harness.ReasonModelError, Err: errors.New("429 rate limited")}, tr)
if !strings.Contains(sum, "model_error") {
t.Errorf("model_error 摘要不对: %q", sum)
}
if !strings.Contains(detail, "429 rate limited") {
t.Errorf("详情没带上底层错误:\n%s", detail)
}
}
// every enumerated cause must carry both a compact headline (for the collapsed
// row) and a full explanation (for the expanded detail).
func TestAbortCausesAreWellFormed(t *testing.T) {
all := []*AbortCause{
AbortPausedByUser, AbortPausedByOrchestrator, AbortTaskDeleted, AbortPausedOnReload,
AbortGoalMet, AbortSettleDrainTimeout, AbortKilledByPlanner, AbortWorkFinished,
AbortPausedRaceGuard, AbortChatStoppedByUser, AbortChatTurnFinished,
AbortShutdown, AbortRunHardTimeout,
}
seen := map[string]bool{}
for _, c := range all {
switch {
case c.Code == "" || seen[c.Code]:
t.Errorf("code 缺失或重复: %q", c.Code)
case c.Short == "" || utf8.RuneCountInString(c.Short) > 40:
t.Errorf("%s: Short 必须存在且 ≤40 字(进折叠摘要行): %q", c.Code, c.Short)
case utf8.RuneCountInString(c.Text) <= utf8.RuneCountInString(c.Short):
t.Errorf("%s: Text 应比 Short 更完整", c.Code)
}
seen[c.Code] = true
}
}
// summaries are mostly 中文; capping by bytes used to slice a rune in half.
func TestFirstLineCapsByRuneNotByte(t *testing.T) {
got := firstLine(strings.Repeat("恢复", 10), 5)
if got != "恢复恢复恢…" {
t.Errorf("firstLine 按字节截断了: %q", got)
}
if got := firstLine("头\n尾", 100); got != "头" {
t.Errorf("多行没截到首行: %q", got)
}
}
+1 -1
View File
@@ -361,7 +361,7 @@ func (w *Worker) Execute(ctx context.Context, name string, taskID int64, as *db.
runCtx := ctx runCtx := ctx
if maxDur > 0 { if maxDur > 0 {
var runCancel context.CancelFunc var runCancel context.CancelFunc
runCtx, runCancel = context.WithTimeout(ctx, maxDur+settleHardGrace) runCtx, runCancel = context.WithTimeoutCause(ctx, maxDur+settleHardGrace, AbortRunHardTimeout)
defer runCancel() defer runCancel()
} }
_, reason, err := captureRunSession(runCtx, s, input, emitWrap) _, reason, err := captureRunSession(runCtx, s, input, emitWrap)
+10 -1
View File
@@ -16,6 +16,7 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/config" "github.com/Autumn-27/artex/config"
"github.com/Autumn-27/artex/server" "github.com/Autumn-27/artex/server"
) )
@@ -70,8 +71,16 @@ func main() {
log.Printf("[config] 配置文件: %s (不存在 — 将仅尝试环境变量 ARTEX_PG_DSN)", cfgPath) log.Printf("[config] 配置文件: %s (不存在 — 将仅尝试环境变量 ARTEX_PG_DSN)", cfgPath)
} }
ctx, 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 "后端进程正在
// 关停" in its trace instead of a bare context.Canceled (see agent/cancelcause.go).
ctx, shutdown := context.WithCancelCause(sigCtx)
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 {
+4 -3
View File
@@ -11,6 +11,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/db" "github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/intercept" "github.com/Autumn-27/artex/intercept"
) )
@@ -243,7 +244,7 @@ func (s *Server) pgStopConversation(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"status": "idle"}) // nothing running writeJSON(w, 200, map[string]any{"status": "idle"}) // nothing running
return return
} }
cancel() cancel(agent.AbortChatStoppedByUser)
writeJSON(w, 200, map[string]any{"status": "stopping"}) writeJSON(w, 200, map[string]any{"status": "stopping"})
} }
@@ -331,12 +332,12 @@ func (s *Server) runConversation(c *db.Conversation, msg, busyKey string) {
func (s *Server) runConversationSync(c *db.Conversation, msg, busyKey string) { func (s *Server) runConversationSync(c *db.Conversation, msg, busyKey string) {
// Per-run cancellable context so a manual stop (pgStopConversation) can abort // Per-run cancellable context so a manual stop (pgStopConversation) can abort
// just this session. Registered under chatMu so the stop handler can find it. // just this session. Registered under chatMu so the stop handler can find it.
ctx, cancel := context.WithCancel(intercept.WithConvID(s.ctx, c.ID)) ctx, cancel := context.WithCancelCause(intercept.WithConvID(s.ctx, c.ID))
s.chatMu.Lock() s.chatMu.Lock()
s.chatCancel[busyKey] = cancel s.chatCancel[busyKey] = cancel
s.chatMu.Unlock() s.chatMu.Unlock()
defer func() { defer func() {
cancel() cancel(agent.AbortChatTurnFinished)
s.chatMu.Lock() s.chatMu.Lock()
delete(s.chatBusy, busyKey) delete(s.chatBusy, busyKey)
delete(s.chatCancel, busyKey) delete(s.chatCancel, busyKey)
+23 -17
View File
@@ -97,14 +97,17 @@ type Engine struct {
// 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.
// CancelCauseFunc (not CancelFunc): every cancel site attaches a named
// *agent.AbortCause so the run's trace can say WHICH of the ~10 cancel
// paths fired instead of one catch-all "运行被中断" line.
execMu sync.Mutex execMu sync.Mutex
execCancel map[string]context.CancelFunc 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 (per running intent) cancel, so the planner's kill_work can stop a
// single worker without pausing the whole task. Keyed by globally-unique intent id. // single worker without pausing the whole task. Keyed by globally-unique intent id.
workMu sync.Mutex workMu sync.Mutex
workCancel map[int64]context.CancelFunc workCancel map[int64]context.CancelCauseFunc
// 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
@@ -136,18 +139,21 @@ 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).
func (e *Engine) Pause(taskID string) { // cause says WHY (user button / orchestrator tool / delete / reload) — it surfaces
// verbatim in the cancelled run's trace, so pass the matching agent.Abort* value.
func (e *Engine) Pause(taskID string, cause error) {
e.paused.Store(taskID, true) e.paused.Store(taskID, true)
e.cancelExec(taskID) e.cancelExec(taskID, cause)
} }
// 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. // sequence's hard-drain backstop. cause is recovered by the aborted run via
func (e *Engine) cancelExec(taskID string) { // context.Cause and printed in its result record.
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 {
cancel() cancel(cause)
} }
e.execMu.Unlock() e.execMu.Unlock()
} }
@@ -166,14 +172,14 @@ func (e *Engine) execContextFor(parent context.Context, taskID string) context.C
defer e.execMu.Unlock() defer e.execMu.Unlock()
if e.IsPaused(taskID) { if e.IsPaused(taskID) {
// never hand out a live context while paused (guards the claim→Execute race) // never hand out a live context while paused (guards the claim→Execute race)
c, cancel := context.WithCancel(parent) c, cancel := context.WithCancelCause(parent)
cancel() cancel(agent.AbortPausedRaceGuard)
return c return c
} }
if c := e.execCtx[taskID]; c != nil && c.Err() == nil { if c := e.execCtx[taskID]; c != nil && c.Err() == nil {
return c return c
} }
c, cancel := context.WithCancel(parent) c, cancel := context.WithCancelCause(parent)
e.execCtx[taskID] = c e.execCtx[taskID] = c
e.execCancel[taskID] = cancel e.execCancel[taskID] = cancel
return c return c
@@ -204,12 +210,12 @@ 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.CancelFunc{}, execCtx: map[string]context.Context{}, execCancel: map[string]context.CancelCauseFunc{}, execCtx: map[string]context.Context{},
workCancel: map[int64]context.CancelFunc{}, steerBox: map[int64][]string{}} workCancel: map[int64]context.CancelCauseFunc{}, steerBox: map[int64][]string{}}
} }
// 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.CancelFunc) { func (e *Engine) registerWork(intentID int64, cancel context.CancelCauseFunc) {
e.workMu.Lock() e.workMu.Lock()
e.workCancel[intentID] = cancel e.workCancel[intentID] = cancel
e.workMu.Unlock() e.workMu.Unlock()
@@ -220,7 +226,7 @@ func (e *Engine) unregisterWork(intentID int64) {
e.workMu.Lock() e.workMu.Lock()
if c := e.workCancel[intentID]; c != nil { if c := e.workCancel[intentID]; c != nil {
delete(e.workCancel, intentID) delete(e.workCancel, intentID)
c() // release context resources (no-op if already cancelled) c(agent.AbortWorkFinished) // release context resources (no-op if already cancelled)
} }
e.workMu.Unlock() e.workMu.Unlock()
e.steerMu.Lock() e.steerMu.Lock()
@@ -306,7 +312,7 @@ func (e *Engine) KillWork(intentID int64) error {
if c == nil { if c == nil {
return fmt.Errorf("意图 %d 当前没有运行中的 work(可能已结束或未被领取)", intentID) return fmt.Errorf("意图 %d 当前没有运行中的 work(可能已结束或未被领取)", intentID)
} }
c() c(agent.AbortKilledByPlanner)
return nil return nil
} }
@@ -530,7 +536,7 @@ func (e *Engine) plannerLoop(ctx context.Context, t *Task) {
// 任务已判完成 → 立刻取消在跑的 worker:它们手头的意图跑出来也没意义了。 // 任务已判完成 → 立刻取消在跑的 worker:它们手头的意图跑出来也没意义了。
// 下一轮 worker 循环撞终态门就不再领新意图;被取消的这批走下方"任务已完成"分支 // 下一轮 worker 循环撞终态门就不再领新意图;被取消的这批走下方"任务已完成"分支
// 归为 stopped(而非 blocked)。 // 归为 stopped(而非 blocked)。
e.cancelExec(t.ID) e.cancelExec(t.ID, agent.AbortGoalMet)
default: default:
log.Printf("[planner] task %s 规划完成", t.ID) log.Printf("[planner] task %s 规划完成", t.ID)
} }
@@ -597,7 +603,7 @@ func (e *Engine) workerLoop(ctx context.Context, t *Task, name string) {
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
// per-work child context so the planner's kill_work can stop just this work. // per-work child context so the planner's kill_work can stop just this work.
workCtx, workCancel := context.WithCancel(ectx) workCtx, workCancel := context.WithCancelCause(ectx)
e.registerWork(intent.ID, workCancel) e.registerWork(intent.ID, workCancel)
// wrap the guard hooks so steer_work can inject a mid-run course-correction // 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). // for THIS intent (drained before the worker's next tool call).
+96
View File
@@ -0,0 +1,96 @@
package server
import (
"context"
"testing"
"github.com/Autumn-27/artex/agent"
)
// The engine's cancel paths must hand a NAMED cause to the run being cancelled —
// that cause is the whole content of the "运行被中断" line in the trace. These
// exercise the context machinery only (no Manager / DB needed).
func TestPauseCancelsWithNamedCause(t *testing.T) {
e := NewEngine(nil)
ectx := e.execContextFor(context.Background(), "t1")
// the per-work child the worker loop actually runs under
workCtx, workCancel := context.WithCancelCause(ectx)
defer workCancel(nil)
e.Pause("t1", agent.AbortPausedByUser)
if code, _, _, ok := agent.AbortReason(workCtx); !ok || code != "paused_by_user" {
t.Fatalf("work ctx 拿到的原因 code=%q ok=%v,期望 paused_by_user", code, ok)
}
}
func TestKillWorkCancelsOnlyThatWorkWithNamedCause(t *testing.T) {
e := NewEngine(nil)
ectx := e.execContextFor(context.Background(), "t1")
workCtx, workCancel := context.WithCancelCause(ectx)
e.registerWork(42, workCancel)
if err := e.KillWork(42); err != nil {
t.Fatalf("KillWork: %v", err)
}
if code, _, _, ok := agent.AbortReason(workCtx); !ok || code != "killed_by_planner" {
t.Fatalf("code=%q ok=%v,期望 killed_by_planner", code, ok)
}
// the task ctx must survive — kill_work stops one work, not the whole task
if ectx.Err() != nil {
t.Error("kill_work 不该取消整个任务的 exec ctx")
}
if err := e.KillWork(999); err == nil {
t.Error("对没有在跑的意图 KillWork 应该报错")
}
}
func TestSettleDrainAndGoalMetCausesAreDistinct(t *testing.T) {
for _, tc := range []struct {
name string
cause *agent.AbortCause
want string
}{
{"settle", agent.AbortSettleDrainTimeout, "settle_drain_timeout"},
{"goalMet", agent.AbortGoalMet, "goal_met"},
} {
t.Run(tc.name, func(t *testing.T) {
e := NewEngine(nil)
ectx := e.execContextFor(context.Background(), "t1")
e.cancelExec("t1", tc.cause)
if code, _, _, _ := agent.AbortReason(ectx); code != tc.want {
t.Fatalf("code=%q,期望 %q", code, tc.want)
}
})
}
}
// asking for an exec ctx while paused must return an already-cancelled ctx that
// still SAYS why — this is the claim→Execute race guard, easily mistaken for a
// mystery abort.
func TestExecContextWhilePausedCarriesRaceGuardCause(t *testing.T) {
e := NewEngine(nil)
e.Pause("t1", agent.AbortPausedByUser)
ctx := e.execContextFor(context.Background(), "t1")
if ctx.Err() == nil {
t.Fatal("暂停期间不该发出可用的 exec ctx")
}
if code, _, _, _ := agent.AbortReason(ctx); code != "paused_race_guard" {
t.Fatalf("code=%q,期望 paused_race_guard", code)
}
}
// Resume must hand out a live context again (cancelling is one-shot).
func TestResumeYieldsFreshContext(t *testing.T) {
e := NewEngine(nil)
old := e.execContextFor(context.Background(), "t1")
e.Pause("t1", agent.AbortPausedByUser)
if old.Err() == nil {
t.Fatal("Pause 没取消旧 ctx")
}
e.paused.Delete("t1") // Resume() without the Task/Notify plumbing
if fresh := e.execContextFor(context.Background(), "t1"); fresh.Err() != nil {
t.Fatal("恢复后应拿到全新可用的 exec ctx")
}
}
+1 -1
View File
@@ -159,7 +159,7 @@ func (e *Engine) settleTask(ctx context.Context, t *Task) {
for e.inflightCount(t.ID) > 0 { for e.inflightCount(t.ID) > 0 {
if time.Now().After(hardStop) { if time.Now().After(hardStop) {
log.Printf("[deadline] task %s drain 超时(%s),硬取消在跑 run", t.ID, settleDrainGrace) log.Printf("[deadline] task %s drain 超时(%s),硬取消在跑 run", t.ID, settleDrainGrace)
e.cancelExec(t.ID) e.cancelExec(t.ID, agent.AbortSettleDrainTimeout)
_ = sleepCtx(ctx, 3*time.Second) // 给 worker 分支一点时间落库/归类 _ = sleepCtx(ctx, 3*time.Second) // 给 worker 分支一点时间落库/归类
break break
} }
+1 -1
View File
@@ -304,7 +304,7 @@ func (s *Server) toolPauseTask() actool.CoreTool {
if _, ok := s.m.Task(a.TaskID); !ok { if _, ok := s.m.Task(a.TaskID); !ok {
return actool.Errorf("task 不存在: " + a.TaskID), nil return actool.Errorf("task 不存在: " + a.TaskID), nil
} }
s.engine.Pause(a.TaskID) s.engine.Pause(a.TaskID, agent.AbortPausedByOrchestrator)
return actool.Text("task paused: " + a.TaskID), nil return actool.Text("task paused: " + a.TaskID), nil
}) })
} }
+7 -7
View File
@@ -60,7 +60,7 @@ type Server struct {
// convBusyKey), so a manual stop can abort JUST that session's agent run. Set/ // convBusyKey), so a manual stop can abort JUST that session's agent run. Set/
// cleared alongside chatBusy under chatMu. Aborting a run does NOT touch the P3 // cleared alongside chatBusy under chatMu. Aborting a run does NOT touch the P3
// trigger queue — the drain goroutine simply proceeds to the next queued fire. // trigger queue — the drain goroutine simply proceeds to the next queued fire.
chatCancel map[string]context.CancelFunc chatCancel map[string]context.CancelCauseFunc
// triggerQ buffers P3 trigger fires PER AGENT. A per-agent "pump" launches runs up // triggerQ buffers P3 trigger fires PER AGENT. A per-agent "pump" launches runs up
// to a concurrency limit derived from the agent's策略: serial → limit 1 (+ optional // to a concurrency limit derived from the agent's策略: serial → limit 1 (+ optional
@@ -123,7 +123,7 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
log.Fatalf("[auth] JWT key: %v", err) log.Fatalf("[auth] JWT key: %v", err)
} }
s := &Server{m: m, engine: NewEngine(m), ctx: ctx, skillDir: skillDir, jwtKey: key, chatBusy: map[string]bool{}, s := &Server{m: m, engine: NewEngine(m), ctx: ctx, skillDir: skillDir, jwtKey: key, chatBusy: map[string]bool{},
chatCancel: map[string]context.CancelFunc{}, triggerQ: map[string][]triggeredRun{}, chatCancel: map[string]context.CancelCauseFunc{}, triggerQ: map[string][]triggeredRun{},
triggerActive: map[string]int{}, triggerCfg: map[string]triggerBehavior{}, triggerActive: map[string]int{}, triggerCfg: map[string]triggerBehavior{},
profAgents: map[int64]*profBundle{}, profChatAgents: map[int64]*agent.ChatAgent{}, profAgents: map[int64]*profBundle{}, profChatAgents: map[int64]*agent.ChatAgent{},
provByProfile: map[int64]*provEntry{}} provByProfile: map[int64]*provEntry{}}
@@ -217,7 +217,7 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
log.Printf("[engine] task %s 重置 %d 个残留 running 意图为 open", t.ID, n) log.Printf("[engine] task %s 重置 %d 个残留 running 意图为 open", t.ID, n)
} }
if t.Paused { if t.Paused {
s.engine.Pause(t.ID) s.engine.Pause(t.ID, agent.AbortPausedOnReload)
} }
// 任务级超时:为每个未终态、带 timeout 的任务起 deadline 协调器,独立于 planner/worker // 任务级超时:为每个未终态、带 timeout 的任务起 deadline 协调器,独立于 planner/worker
// loop——非活跃任务重启后也能在到点后被收尾(deadline 已过则立即走收尾时序)。 // loop——非活跃任务重启后也能在到点后被收尾(deadline 已过则立即走收尾时序)。
@@ -902,7 +902,7 @@ func (s *Server) control(w http.ResponseWriter, r *http.Request) {
} }
switch req.Action { switch req.Action {
case "pause": case "pause":
s.engine.Pause(t.ID) s.engine.Pause(t.ID, agent.AbortPausedByUser)
case "resume": case "resume":
s.engine.Run(s.ctx, t) // ensure loops are alive, then un-pause + nudge s.engine.Run(s.ctx, t) // ensure loops are alive, then un-pause + nudge
s.engine.Resume(t) s.engine.Resume(t)
@@ -2330,7 +2330,7 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) {
} }
// Cancellable context so stopChat can abort this turn without affecting // Cancellable context so stopChat can abort this turn without affecting
// the server's root context (mirrors the conversation stop pattern). // the server's root context (mirrors the conversation stop pattern).
ctx, cancel := context.WithCancel(s.ctx) ctx, cancel := context.WithCancelCause(s.ctx)
s.chatBusy[t.ID] = true s.chatBusy[t.ID] = true
s.chatCancel[t.ID] = cancel s.chatCancel[t.ID] = cancel
s.chatMu.Unlock() s.chatMu.Unlock()
@@ -2342,7 +2342,7 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) {
// handler returns immediately and the browser never needs to hold the request. // handler returns immediately and the browser never needs to hold the request.
go func() { go func() {
defer func() { defer func() {
cancel() cancel(agent.AbortChatTurnFinished)
s.chatMu.Lock() s.chatMu.Lock()
delete(s.chatBusy, t.ID) delete(s.chatBusy, t.ID)
delete(s.chatCancel, t.ID) delete(s.chatCancel, t.ID)
@@ -2386,7 +2386,7 @@ func (s *Server) stopChat(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"status": "idle"}) writeJSON(w, 200, map[string]any{"status": "idle"})
return return
} }
cancel() cancel(agent.AbortChatStoppedByUser)
writeJSON(w, 200, map[string]any{"status": "stopping"}) writeJSON(w, 200, map[string]any{"status": "stopping"})
} }
+1 -1
View File
@@ -68,7 +68,7 @@ func decode(r *http.Request, v any) error { return json.NewDecoder(r.Body).Decod
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 := r.PathValue("id")
s.engine.Pause(id) // cancel running loops before removing s.engine.Pause(id, agent.AbortTaskDeleted) // cancel running loops before removing
if err := s.m.DeleteTask(id); err != nil { if err := s.m.DeleteTask(id); err != nil {
writeErr(w, 500, err.Error()) writeErr(w, 500, err.Error())
return return
@@ -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">版本·v{version}</span> <span className="text-muted-foreground text-xs font-medium tabular-nums">版本 · {version}</span>
)} )}
<LayoutControls /> <LayoutControls />
<ThemeSwitcher /> <ThemeSwitcher />
+10 -5
View File
@@ -183,7 +183,12 @@ function NewProfileDialog({ onCreated }: { onCreated: (id: string) => void }) {
value={model} value={model}
onChange={(e) => setModel(e.target.value)} onChange={(e) => setModel(e.target.value)}
/> />
<Popover open={modelsOpen} onOpenChange={setModelsOpen}> {/* modal: this Popover lives inside a Dialog, and its content is
portaled to <body> — outside the Dialog's react-remove-scroll
lock, which then cancels every wheel event over it (the list
rendered but refused to scroll). modal makes the Popover own
the topmost scroll lock, so its own list scrolls again. */}
<Popover open={modelsOpen} onOpenChange={setModelsOpen} modal>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
type="button" type="button"
@@ -198,12 +203,12 @@ function NewProfileDialog({ onCreated }: { onCreated: (id: string) => void }) {
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
{models.length > 0 && ( {models.length > 0 && (
<PopoverContent className="w-72 max-h-64 overflow-y-auto p-1" align="end"> <PopoverContent className="max-h-72 w-72 gap-0 overflow-y-auto overscroll-contain p-1" align="end">
{models.map((m) => ( {models.map((m) => (
<button <button
key={m} key={m}
type="button" type="button"
className="w-full rounded-md px-2 py-1.5 text-left font-mono text-xs hover:bg-accent hover:text-accent-foreground" className="w-full shrink-0 rounded-md px-2 py-1.5 text-left font-mono text-xs hover:bg-accent hover:text-accent-foreground"
onClick={() => { onClick={() => {
setModel(m); setModel(m);
setModelsOpen(false); setModelsOpen(false);
@@ -567,12 +572,12 @@ export default function LLMPage() {
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
{models.length > 0 && ( {models.length > 0 && (
<PopoverContent className="w-72 max-h-64 overflow-y-auto p-1" align="end"> <PopoverContent className="max-h-72 w-72 gap-0 overflow-y-auto overscroll-contain p-1" align="end">
{models.map((m) => ( {models.map((m) => (
<button <button
key={m} key={m}
type="button" type="button"
className="w-full rounded-md px-2 py-1.5 text-left font-mono text-xs hover:bg-accent hover:text-accent-foreground" className="w-full shrink-0 rounded-md px-2 py-1.5 text-left font-mono text-xs hover:bg-accent hover:text-accent-foreground"
onClick={() => { onClick={() => {
setModel(m); setModel(m);
setModelsOpen(false); setModelsOpen(false);