Files
Agent-System-Backend/internal/runmgr/runmgr.go
T
weichenandCursor 44aa764b56
ci / vet-and-test (pull_request) Successful in 43s
Recover interrupted runs and commit terminal state once
Why: A hard kill left runs in pending, running, or waiting_hitl, a busy send stored a message before the 409, finishRun could mark a run completed without an assistant message, and a full SSE buffer dropped events without closing the stream.
Evidence: GOWORK=off go vet ./... and GOWORK=off go test -count=1 ./... passed, including store, runmgr, bus, and server recovery tests.
Impact: Migration v14 adds runs.instance_id and messages.request_id. Startup reconciliation and shutdown share one seal. Turn and workflow sends claim a slot before writing. Duplicate request ids return the existing message. SettleRun updates the run and appends the assistant message in one transaction. A full subscriber buffer closes the channel so SSE can replay from the cursor.
Refs: Dev feature/pi-durable-borrow
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 14:37:49 +00:00

1512 lines
51 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package runmgr 是 Run 生命周期枢纽(4.2/4.3):一切运行皆 Run、一切增量皆事件。
// 职责:run 状态机、事件持久化+广播(seq 游标)、HITL 挂起/恢复(三分支路由)、
// 子代理派生(任务树)、工具执行策略(画像三清单 + 确认卡片 + 截断转工件)、取消级联。
package runmgr
import (
"context"
"errors"
"fmt"
"log"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"agent-system-backend/internal/bus"
"agent-system-backend/internal/cardsvc"
"agent-system-backend/internal/ingest"
"agent-system-backend/internal/profiles"
"agent-system-backend/internal/runtime"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/internal/tools"
"agent-system-backend/internal/workflow"
core "agent-system-core"
)
type Manager struct {
st *store.Store
bus *bus.Bus
registry *tools.Registry
profiles *profiles.Service
settings *settings.Service
runner runtime.Runner // conversation runner(mock 或 LLM)
subRunner runtime.Runner // subagent runner(LLM 轻任务)
mock runtime.Runner // 会话脚本回放(演示/测试与 mock 模式)
mockSub runtime.Runner // 子代理脚本回放
squad runtime.Runner // runtime.entry=squad 时的对话入口
wfRunner runtime.Runner // 意图或工具启动的工作流;nil 时用 runtime.WorkflowRunner
workflows *workflow.Registry // run_workflow 按 id 查找已注册定义
gate IntentGate
cards *cardsvc.Service // 可选意图闸门(nil = 未启用,行为回退)
// OnUsage 在 run 结束时报告 token(日预算记账;nil = 不记)。
OnUsage func(owner string, tokens int)
events *eventBuffer // 事件批处理(写放大治理;seq 权威仍在 store)
retriever core.Retriever // 可选混合检索(nil = store 全文)
indexer core.DocumentIndexer // 可选索引入口(nil = store 整文档单块)
// workspaceRoot fs 预留能力(Hidden 工具族)的沙箱根;空=未配置,工具执行时报错。
workspaceRoot string
mu sync.Mutex
active map[string]*runHandle
// 会话内串行(设计并发策略):一个会话同时至多一个在途 conversation run。
convMu sync.Mutex
convActive map[string]bool // conversationID → 有在途 run
instanceID string
shuttingDown atomic.Bool
// 后台子代理的完成通知只留在内存里。重启后未投递的通知丢失。
noticeMu sync.Mutex
notices map[string][]bgNotice
bgInFlight map[string]int
delivering map[string]bool
}
const maxBackgroundChildren = 3
// bgNotice 是一条尚未投递给父会话的后台子代理结果。
type bgNotice struct {
owner string
name string
payload core.AgentResultPayload
}
// ErrConversationBusy 表示会话已有在途 run(HTTP 层映射 409)。
var ErrConversationBusy = fmt.Errorf("runmgr: conversation already has an in-flight run")
type runHandle struct {
cancel context.CancelFunc
hitlCh chan hitlAnswer
assistant []core.Part
ctxObs *core.ContextObservation
submittedCards []core.ID // submit_card 产物卡(finishRun 回填锚点)
streamCardID core.ID // 活跃流式产物卡(card_begin/card_commit 三段式;D44/D45)
started time.Time
promptTokens int
completionTokens int
}
type hitlAnswer struct {
resp core.HITLResponse
err error
}
func New(st *store.Store, b *bus.Bus, reg *tools.Registry, p *profiles.Service, s *settings.Service, runner, subRunner, mockRunner, mockSubRunner runtime.Runner) *Manager {
return &Manager{
st: st, bus: b, registry: reg, profiles: p, settings: s,
runner: runner, subRunner: subRunner, mock: mockRunner, mockSub: mockSubRunner,
active: map[string]*runHandle{}, convActive: map[string]bool{},
notices: map[string][]bgNotice{}, bgInFlight: map[string]int{}, delivering: map[string]bool{},
events: newEventBuffer(st, b),
}
}
// Shutdown 停机清空事件缓冲(在 store.Close 之前调用)。
func (m *Manager) Shutdown() { m.events.stop() }
// SetInstanceID 记录本进程标识。启动对账在 PG 档只收口这个实例的 run。
func (m *Manager) SetInstanceID(id string) { m.instanceID = id }
// SetEventNotifier 注入落盘后回调(PG 档 pgbus.Notifier;SQLite 档不注入)。
func (m *Manager) SetEventNotifier(fn func(conversationID string, evs []core.StreamEvent)) {
m.events.onPersisted = fn
}
// IntentGate 是可选意图闸门端口(intentgate.Gate 实现;nil = 未启用,行为回退)。
type IntentGate interface {
Match(ctx context.Context, input string) (branch, topID string)
}
// SetGate 注入意图闸门(装配期一次;插件意图 overlay 经 pluginhost 回调重建)。
func (m *Manager) SetGate(g IntentGate) { m.gate = g }
// SetCards 注入收件箱卡服务(nil = 不产卡;T0 退回基线仍成立)。
func (m *Manager) SetCards(s *cardsvc.Service) { m.cards = s }
// EmitCardEvent 供服务层(cardsvc 投影)复用会话事件管道(持久化 + seq + 扇出)。
func (m *Manager) EmitCardEvent(conversationID, runID string, typ core.StreamEventType, payload any) error {
_, err := m.emit(conversationID, runID, typ, payload)
return err
}
// SetRetriever 注入混合检索(rag 管线;nil = store 全文检索)。
func (m *Manager) SetRetriever(r core.Retriever) { m.retriever = r }
// SetIndexer 注入检索索引入口(分块+全文+可选向量;nil = 整文档单块)。
func (m *Manager) SetIndexer(i core.DocumentIndexer) { m.indexer = i }
// SetWorkspaceRoot 注入 fs 预留能力(Hidden 工具族)的沙箱根目录。
// 实际会话沙箱为 <root>/<owner>/<conversationID>/;空值=未配置(工具执行报错)。
func (m *Manager) SetWorkspaceRoot(dir string) { m.workspaceRoot = dir }
// ---- runCtx 实现(runtime.RunCtx)----
type runCtx struct {
mgr *Manager
run core.Run
prof core.AgentProfile
eff core.EffectiveSettings
ctx context.Context
h *runHandle
userMessage core.Message
attachments []core.ParsedDocument
inputPaths []string
subTask string
subHint string
subAgent string
launch *workflow.Launch
}
// Workflow 返回这次 run 启动时拷贝的工作流。普通回合没有。
func (c *runCtx) Workflow() (workflow.Launch, bool) {
if c.launch == nil {
return workflow.Launch{}, false
}
return *c.launch, true
}
// WorkflowByID 按 id 取当前注册表里的定义。运行中的副本不受之后卸载的影响。
func (c *runCtx) WorkflowByID(id string) (core.WorkflowDef, bool) {
if c.mgr.workflows == nil {
return core.WorkflowDef{}, false
}
return c.mgr.workflows.Get(id)
}
// WorkflowScript 读取插件脚本的当前正文。启动工作流时会另外拷进 Launch。
func (c *runCtx) WorkflowScript(id, path string) (string, bool) {
if c.mgr.workflows == nil {
return "", false
}
return c.mgr.workflows.Script(id, path)
}
func (c *runCtx) Context() context.Context { return c.ctx }
func (c *runCtx) Run() core.Run { return c.run }
func (c *runCtx) Profile() core.AgentProfile { return c.prof }
func (c *runCtx) Settings() core.EffectiveSettings { return c.eff }
func (c *runCtx) History() []core.Message {
hist, err := c.mgr.st.ListMessages(c.run.ConversationID)
if err != nil {
return nil
}
if sub, qerr := c.mgr.st.SubagentRunIDs(c.run.ConversationID); qerr == nil && len(sub) > 0 {
kept := hist[:0]
for _, m := range hist {
if m.RunID != "" && sub[m.RunID] {
continue
}
kept = append(kept, m)
}
hist = kept
}
if len(hist) > 200 {
hist = hist[len(hist)-200:]
}
return hist
}
func (c *runCtx) Attachments() []core.ParsedDocument { return c.attachments }
func (c *runCtx) WorkspaceInputs() []string { return c.inputPaths }
// ConversationSandbox 是会话工作区根。宿主工具把内核步骤里生成的文件复制到这里再登记。
func (c *runCtx) ConversationSandbox() string {
if c.mgr.workspaceRoot == "" || c.run.Owner == "" || c.run.ConversationID == "" {
return ""
}
return filepath.Join(c.mgr.workspaceRoot, c.run.Owner, c.run.ConversationID)
}
// AddUsage 累加本 run 的模型用量,finishRun 写入 run.Usage。
func (c *runCtx) AddUsage(u core.Usage) {
c.mgr.mu.Lock()
c.h.promptTokens += u.PromptTokens
c.h.completionTokens += u.CompletionTokens
c.mgr.mu.Unlock()
}
func (c *runCtx) Subtask() string { return c.subTask }
// Tools 模型可见工具清单:Hidden 工具(fs 预留能力)在 runtime.fsTools 未开时过滤
// (与 /api/tools 列表同门禁;registry.Execute 另有执行侧 fail-closed)。
func (c *runCtx) Tools() []core.ToolDesc {
list := c.mgr.registry.List()
if c.eff.GetBool("runtime.fsTools", false) {
return list
}
out := make([]core.ToolDesc, 0, len(list))
for _, t := range list {
if !t.Hidden {
out = append(out, t)
}
}
return out
}
func (c *runCtx) SetContextObservation(o core.ContextObservation) {
c.mgr.mu.Lock()
c.h.ctxObs = &o
c.mgr.mu.Unlock()
}
func (c *runCtx) Memories() []core.MemoryEntry {
if c.mgr == nil || c.mgr.st == nil {
return nil
}
list, err := c.mgr.st.ListMemory(c.run.Owner, "", "")
if err != nil {
return nil
}
now := time.Now()
var live []core.MemoryEntry
for _, e := range list {
if e.ExpiresAt != nil && !e.ExpiresAt.After(now) {
continue
}
live = append(live, e)
}
return live
}
func (c *runCtx) Emit(typ core.StreamEventType, payload any) error {
_, err := c.mgr.emit(c.run.ConversationID, c.run.ID, typ, payload)
return err
}
func (c *runCtx) SetAssistantParts(parts []core.Part) error {
c.mgr.mu.Lock()
c.h.assistant = parts
c.mgr.mu.Unlock()
return nil
}
func (c *runCtx) AskHITL(card core.HITLCard) (core.HITLResponse, error) {
card.ID = store.NewID("hitl")
card.RunID = c.run.ID
card.ConversationID = c.run.ConversationID
if card.CreatedAt == "" {
card.CreatedAt = time.Now().UTC().Format(time.RFC3339)
}
if err := c.mgr.st.SaveHITLCard(c.run.Owner, &card); err != nil {
return core.HITLResponse{}, err
}
c.mgr.setRunStatus(&c.run, core.RunWaitingHITL, c.run.Phase)
if _, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvHITLRequest, card); err != nil {
return core.HITLResponse{}, err
}
// 收件箱投影卡(双现):待确认进收件箱不沉没;ref 联动使任一侧响应即双侧销项。
if c.mgr.cards != nil {
proj := core.ActivityCard{
ProjectID: c.run.ProjectID, ConversationID: c.run.ConversationID, SourceRunID: c.run.ID,
Kind: core.CardKindConfirm, Title: card.Question, Status: core.CardActionRequired,
Tone: map[bool]string{true: "danger", false: "warn"}[card.Blocked],
}
if card.Blocked {
proj.Body = append(proj.Body, core.UINode{Type: core.UIMarkdown, Markdown: "⚠ 阻断:" + card.BlockReason})
}
for _, ch := range card.Choices {
proj.Actions = append(proj.Actions, core.UIAction{ID: ch.ID, Label: ch.Label, Style: map[bool]string{true: "primary", false: "default"}[ch.Recommended]})
}
if card.AllowFreeInput {
proj.Body = append(proj.Body, core.UINode{Type: core.UIMarkdown, Markdown: "_可在输入框自由输入回应。_"})
}
if _, err := c.mgr.cards.Create(c.run.Owner, proj, "hitl:"+card.ID); err != nil {
return core.HITLResponse{}, err
}
}
var timeout time.Duration
if card.TimeoutSec != nil && *card.TimeoutSec > 0 {
timeout = time.Duration(*card.TimeoutSec) * time.Second
}
return waitHITL(c.ctx, c.h.hitlCh, timeout)
}
func waitHITL(ctx context.Context, ch <-chan hitlAnswer, timeout time.Duration) (core.HITLResponse, error) {
if timeout <= 0 {
select {
case ans := <-ch:
return ans.resp, ans.err
case <-ctx.Done():
return core.HITLResponse{}, ctx.Err()
}
}
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case ans := <-ch:
return ans.resp, ans.err
case <-timer.C:
return core.HITLResponse{}, fmt.Errorf("runmgr: hitl timed out after %s", timeout)
case <-ctx.Done():
return core.HITLResponse{}, ctx.Err()
}
}
func (c *runCtx) SpawnSubagent(name, task, hint string) (core.AgentResultPayload, error) {
return c.SpawnAgent("agent.sub.general", name, task, hint)
}
func (c *runCtx) SpawnAgent(agentID, name, task, hint string) (core.AgentResultPayload, error) {
return c.mgr.spawnSubagent(c.ctx, c.run, agentID, name, task, hint)
}
func (c *runCtx) SpawnBackground(agentID, name, task, hint string) (core.ID, error) {
return c.mgr.spawnBackground(c.run, agentID, name, task, hint)
}
func (c *runCtx) SubagentID() string { return c.subAgent }
func (c *runCtx) Subhint() string { return c.subHint }
func (c *runCtx) UpdatePlan(p core.PlanSnapshot) error {
p.PlanID = c.run.ConversationID
if err := c.mgr.st.SavePlan(c.run.ConversationID, p); err != nil {
return err
}
_, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvPlanUpdate, core.PlanUpdatePayload{Plan: p})
return err
}
// Todos 读取本会话已有待办。没有记录时返回 false,供终轮提醒当作可选接口。
func (c *runCtx) Todos() (core.TodoList, bool) {
list, err := c.mgr.st.GetTodos(c.run.ConversationID)
if err != nil {
return core.TodoList{}, false
}
return list, true
}
// Plan 读取本会话已有计划。没有记录时返回 false。
func (c *runCtx) Plan() (core.PlanSnapshot, bool) {
p, err := c.mgr.st.GetPlan(c.run.ConversationID)
if err != nil {
return core.PlanSnapshot{}, false
}
return p, true
}
func (c *runCtx) UpdateTodos(l core.TodoList) error {
l.ConversationID = c.run.ConversationID
if err := c.mgr.st.SaveTodos(c.run.ConversationID, l); err != nil {
return err
}
_, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvTodoUpdate, core.TodoUpdatePayload{List: l})
return err
}
// CreateCard / SetCardStatus:run 内产卡入口(摄取/编排/工作流步骤;mock 演示)。
// 收件箱关闭(cards==nil)时为 no-op——T0 退回基线不受影响。
// 归属判定(D48):调用方留空 ConversationID 且预置 ProjectID = 项目级卡
// (scope=project 工具参数),补全 ProjectID、ConversationID 保持空;其余照旧
// 归属当前会话。
func (c *runCtx) CreateCard(card core.ActivityCard) (core.ActivityCard, error) {
if c.mgr.cards == nil {
return card, nil
}
if card.ConversationID == "" && card.ProjectID == "" {
card.ConversationID = c.run.ConversationID
}
card.ProjectID = c.run.ProjectID
card.SourceRunID = c.run.ID
return c.mgr.cards.Create(c.run.Owner, card, "")
}
func (c *runCtx) SetCardStatus(id string, status core.CardStatus, tone, note string) error {
if c.mgr.cards == nil {
return nil
}
_, err := c.mgr.cards.SetStatus(c.run.Owner, id, status, tone, note)
return err
}
// SubmitCard:submit_card 工具入口——建卡并登记 ID,终态消息落库后回填锚点。
func (c *runCtx) SubmitCard(card core.ActivityCard) (core.ActivityCard, error) {
created, err := c.CreateCard(card)
if err != nil {
return created, err
}
c.mgr.mu.Lock()
c.h.submittedCards = append(c.h.submittedCards, created.ID)
c.mgr.mu.Unlock()
return created, nil
}
// BeginStreamCard:card_begin 工具入口——建 active 产物卡并设为活跃流式卡。
// 多次 begin 以最后一卡为活跃(提案 §4);未收尾的旧卡由 finishRun 兜底置态。
// 收件箱关闭(cards==nil 或返回空 ID)时报错,工具层回退为对话内直写。
func (c *runCtx) BeginStreamCard(card core.ActivityCard) (core.ActivityCard, error) {
card.Status = core.CardActive
created, err := c.CreateCard(card)
if err != nil {
return created, err
}
if created.ID == "" {
return created, errors.New("card: inbox disabled")
}
c.mgr.mu.Lock()
c.h.streamCardID = created.ID
c.mgr.mu.Unlock()
return created, nil
}
// AppendStreamCard:card_append 工具入口——向活跃流式卡追加正文(事件节流)。
func (c *runCtx) AppendStreamCard(text string) error {
c.mgr.mu.Lock()
id := c.h.streamCardID
c.mgr.mu.Unlock()
if id == "" {
return errors.New("card: no active stream card (card_begin first)")
}
return c.mgr.cards.Append(c.run.Owner, string(id), text)
}
// CommitStreamCard:card_commit 工具入口——活跃流式卡置 done、登记锚点回填
// 并清空活跃指针。
func (c *runCtx) CommitStreamCard() (core.ActivityCard, error) {
c.mgr.mu.Lock()
id := c.h.streamCardID
c.h.streamCardID = ""
c.mgr.mu.Unlock()
if id == "" {
return core.ActivityCard{}, errors.New("card: no active stream card (card_begin first)")
}
card, err := c.mgr.cards.SetStatus(c.run.Owner, string(id), core.CardDone, "ok", "")
if err != nil {
return card, err
}
c.mgr.mu.Lock()
c.h.submittedCards = append(c.h.submittedCards, card.ID)
c.mgr.mu.Unlock()
return card, nil
}
// Card:按 ID 取卡(ctxasm 回填;无此卡或收件箱关闭返回 nil)。
func (c *runCtx) Card(id string) *core.ActivityCard {
if c.mgr.cards == nil {
return nil
}
card, err := c.mgr.cards.Get(c.run.Owner, id)
if err != nil {
return nil
}
return &card
}
// Artifact:按 ID 取工件与内容(ctxasm 回填 artifact_ref 正文用;跨租户/不存在
// 一律 false,不泄露存在性)。
func (c *runCtx) Artifact(id string) (core.Artifact, []byte, bool) {
art, err := c.mgr.st.GetArtifact(c.run.Owner, string(id))
if err != nil {
return core.Artifact{}, nil, false
}
content, err := c.mgr.st.ArtifactContent(c.run.Owner, string(id))
if err != nil {
return art, nil, false
}
return art, content, true
}
func (c *runCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) {
art := core.Artifact{ProjectID: c.run.ProjectID, Owner: c.run.Owner, Name: name, Kind: kind, MIME: mime, SourceRun: c.run.ID}
if err := c.mgr.st.CreateArtifact(&art, content); err != nil {
return art, err
}
if _, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvArtifactCreated, core.ArtifactPayload{Artifact: art}); err != nil {
return art, err
}
// 工件文本进检索索引。二进制 Office/PDF 先经 ingest 抽出纯文本,抽到才入库;
// 抽不到则跳过,避免把 ZIP 字节当文本索引。
if kind == "document" && len(content) < 200000 {
if text := indexTextOf(name, mime, content); text != "" {
if c.mgr.indexer != nil {
_ = c.mgr.indexer.IndexDocument(c.run.Owner, c.run.ProjectID, "artifact", name, art.ID, text)
} else {
_ = c.mgr.st.AddKnowledge(core.KnowledgeDoc{Owner: c.run.Owner, ProjectID: c.run.ProjectID, Source: "artifact", Title: name, Ref: art.ID, Chunk: text})
}
}
}
return art, nil
}
// binaryDocMime 判定二进制文档 mime:此类工件字节不能按 UTF-8 文本进索引。
func binaryDocMime(mime string) bool {
switch mime {
case "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.ms-excel",
"application/vnd.ms-powerpoint",
"application/msword",
"application/pdf",
"application/zip":
return true
}
return false
}
// indexTextOf 决定工件进检索索引的文本。普通文本原样返回;二进制文档 mime
// 先走 ingest 抽文本,解析失败或为空则返回空串(跳过索引)。
func indexTextOf(name, mime string, content []byte) string {
if !binaryDocMime(mime) {
return string(content)
}
doc := ingest.Parse(name, mime, content)
if len(doc.Warnings) > 0 || len(doc.Blocks) == 0 {
return ""
}
return strings.TrimSpace(doc.PlainText())
}
func (c *runCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) {
if q.TopK <= 0 {
q.TopK = int(c.eff.GetNumber("retrieval.topK", 5))
}
if q.ProjectID == "" {
q.ProjectID = c.run.ProjectID
}
if c.mgr.retriever != nil {
return c.mgr.retriever.Retrieve(c.ctx, c.run.Owner, q)
}
return c.mgr.st.SearchKnowledge(c.run.Owner, q)
}
// ExecuteTool 执行画像策略:deny→错误;confirm→HITL confirm 卡片;执行→截断转工件。
func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
policy := c.prof.Tools
if !policy.ToolAllowed(call.Name) {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: fmt.Sprintf("tool %q denied by profile policy", call.Name)}
}
if policy.NeedsConfirm(call.Name) {
var argsPreview string
if len(call.Arguments) > 0 {
argsPreview = truncate(string(call.Arguments), 600)
}
card := core.HITLCard{
Question: fmt.Sprintf("执行工具 %s?", call.Name), Kind: core.HITLKindConfirm,
Choices: []core.HITLChoice{
{ID: "yes", Label: "执行", Description: "按上述参数调用", Impact: "该工具将访问外部资源并返回结果", Recommended: true},
{ID: "no", Label: "取消", Description: "跳过本次调用", Impact: "模型会收到调用被拒绝的说明"},
},
AllowFreeInput: false,
}
_ = argsPreview
resp, err := c.AskHITL(card)
if err != nil {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "hitl aborted: " + err.Error()}
}
if resp.ChoiceID != "yes" {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "用户取消了本次工具调用"}
}
}
tc := &toolCtx{rc: c, callID: call.CallID}
res := c.mgr.registry.Execute(tc, call)
// 工具产物回填(交付物卡片根因修复):注册表工具执行期经 rc.WriteArtifact
// 写入的工件(write_artifact / fs_write_file / register_artifact / 未来执行
// 原语产物)挂到结果上——llm.go 依 res.Artifact 生成对话内 artifact_ref 卡片。
// 此前只有「结果超长截断转工件」会设置它,正常产物的卡片从未出现。
if res.Artifact == nil && len(tc.arts) > 0 {
last := tc.arts[len(tc.arts)-1]
res.Artifact = &last
}
// 超长截断并转工件引用(4.10)。
limit := c.prof.Context.MaxToolResultChars
if limit <= 0 {
limit = int(c.eff.GetNumber("context.maxToolResultChars", 20000))
}
if res.Status == "ok" && len(res.Output) > limit {
if art, err := c.WriteArtifact("tool-"+call.Name+"-"+call.CallID+".txt", "data", "text/plain", []byte(res.Output)); err == nil {
res.Artifact = &art
res.Output = res.Output[:limit] + "\n…(超长,完整结果已存为工件 " + art.Name + ")"
} else {
res.Output = res.Output[:limit] + "…(超长截断,工件写入失败)"
}
res.Truncated = true
}
return res
}
// toolCtx 包装 runCtx 供工具执行;arts 记录本调用期间写入的工件,
// ExecuteTool 据此回填 res.Artifact(对话内交付物卡片的来源)。
type toolCtx struct {
rc *runCtx
arts []core.Artifact
callID string
}
func (t *toolCtx) ProjectID() string { return t.rc.run.ProjectID }
func (t *toolCtx) ConversationID() string { return t.rc.run.ConversationID }
func (t *toolCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) {
return t.rc.Retrieve(q)
}
func (t *toolCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) {
art, err := t.rc.WriteArtifact(name, kind, mime, content)
if err == nil {
t.arts = append(t.arts, art)
}
return art, err
}
func (t *toolCtx) Context() context.Context { return t.rc.Context() }
func (t *toolCtx) OwnerID() string { return t.rc.run.Owner }
func (t *toolCtx) WorkspaceRoot() string { return t.rc.mgr.workspaceRoot }
func (t *toolCtx) SettingBool(key string, def bool) bool { return t.rc.eff.GetBool(key, def) }
// EmitToolOutput 把解释器执行中的输出增量发到会话事件流。
func (t *toolCtx) EmitToolOutput(stream, text string) {
if t.callID == "" || text == "" {
return
}
_ = t.rc.Emit(core.EvToolOutput, core.ToolOutputPayload{CallID: t.callID, Stream: stream, Text: text})
}
// ---- 跨会话读取门禁(提案 2026-09-30-cross-session-reading;fail-closed)----
//
// canRead(A→B) = crossSession.read(项目闸门) AND A.allowCrossRead(出站,仅读他人时)
// AND !B.crossPrivate(入站,仅被他人读时) AND 同项目 AND 同租户。
// 出站/入站检查对自读(A==B)豁免:私有会话仍可读自身更早历史。
var (
// ErrCrossReadDisabled 项目闸门关或本会话关闭出站读取。
ErrCrossReadDisabled = errors.New("跨会话读取未开启(项目设置 crossSession.read 或会话 allowCrossRead)")
// ErrCrossReadForbidden 目标不可读:私有(被他人读)或跨项目。
ErrCrossReadForbidden = errors.New("目标会话不可读(私有或不在本项目内)")
)
// crossReadSelf 加载当前会话并校验项目闸门;出站检查由调用方按目标是否为自身决定。
func (t *toolCtx) crossReadSelf() (core.Conversation, error) {
if !t.rc.eff.GetBool("crossSession.read", false) {
return core.Conversation{}, ErrCrossReadDisabled
}
self, err := t.rc.mgr.st.GetConversation(t.rc.run.Owner, t.rc.run.ConversationID)
if err != nil {
return core.Conversation{}, err
}
return self, nil
}
func (t *toolCtx) PeerConversations() ([]core.Conversation, error) {
self, err := t.crossReadSelf()
if err != nil {
return nil, err
}
if !self.AllowCrossRead {
return nil, ErrCrossReadDisabled
}
convs, err := t.rc.mgr.st.ListConversations(t.rc.run.Owner, t.rc.run.ProjectID)
if err != nil {
return nil, err
}
out := make([]core.Conversation, 0, len(convs))
for _, c := range convs {
// 只读他人:排除自身;私有会话不出现(存在性本身是信息)。
if c.ID == self.ID || c.CrossPrivate {
continue
}
out = append(out, c)
}
return out, nil
}
func (t *toolCtx) PeerConversationMessages(conversationID string, limit int) ([]core.Message, error) {
if limit <= 0 {
limit = 50
}
if limit > 200 {
limit = 200
}
self, err := t.crossReadSelf()
if err != nil {
return nil, err
}
// 租户校验先行(GetConversation 按 owner 过滤;跨租户 = NotFound,不泄露存在性)。
target, err := t.rc.mgr.st.GetConversation(t.rc.run.Owner, conversationID)
if err != nil {
return nil, err
}
isSelf := target.ID == self.ID
if !isSelf && !self.AllowCrossRead {
return nil, ErrCrossReadDisabled
}
if !isSelf && (target.CrossPrivate || target.ProjectID != t.rc.run.ProjectID) {
return nil, ErrCrossReadForbidden
}
return t.rc.mgr.st.LastMessages(conversationID, limit)
}
// ---- run 生命周期 ----
// SetSquadRunner 注入 runtime.entry=squad 时使用的对话入口。
func (m *Manager) SetSquadRunner(r runtime.Runner) { m.squad = r }
// SetWorkflowRunner 注入工作流执行器。测试用来替换默认实现。
func (m *Manager) SetWorkflowRunner(r runtime.Runner) { m.wfRunner = r }
// SetWorkflows 注入已装载工作流,供 run_workflow 按 id 查找。
func (m *Manager) SetWorkflows(r *workflow.Registry) { m.workflows = r }
// selectConversationRunner 按设置选择入口。带工作流定义时走工作流执行器;
// 否则 mock 优先,squad 仅在注入了入口时生效。
func selectConversationRunner(m *Manager, eff core.EffectiveSettings, launch *workflow.Launch) runtime.Runner {
if launch != nil {
if m.wfRunner != nil {
return m.wfRunner
}
return runtime.WorkflowRunner{}
}
if eff.GetBool("runtime.mock", false) {
return m.mock
}
if eff.GetString("runtime.entry", "direct") == "squad" && m.squad != nil {
return m.squad
}
return m.runner
}
// StartConversationRun 为一条已落库的用户消息启动 conversation run。
// attachments 为已解析的附件文档(server 层经 ingest 处理)。
// inputs 是同一批附件的原件,落到会话工作区 inputs/,供模板和仿制使用。
func (m *Manager) StartConversationRun(conv core.Conversation, userMessage core.Message, attachments []core.ParsedDocument, inputs []WorkspaceFile) (core.Run, error) {
if err := m.claimConversation(conv.ID); err != nil {
return core.Run{}, err
}
return m.startClaimedRun(conv, userMessage, attachments, inputs, nil)
}
// StartWorkflowRun 启动一条绑定工作流的会话 run。定义在这里拷贝,
// 之后插件卸载不会改到已经在跑的这份。
func (m *Manager) StartWorkflowRun(conv core.Conversation, userMessage core.Message, def core.WorkflowDef, slots map[string]string, missing []workflow.SlotQuestion) (core.Run, error) {
if err := m.claimConversation(conv.ID); err != nil {
return core.Run{}, err
}
copiedSlots := map[string]string{}
for k, v := range slots {
copiedSlots[k] = v
}
launch := &workflow.Launch{
Def: workflow.Clone(def),
Slots: copiedSlots,
Missing: append([]workflow.SlotQuestion(nil), missing...),
Scripts: workflowScriptBodies(m.workflows, def),
}
return m.startClaimedRun(conv, userMessage, nil, nil, launch)
}
func workflowScriptBodies(reg *workflow.Registry, def core.WorkflowDef) map[string]string {
out := map[string]string{}
if reg == nil {
return out
}
for _, step := range def.Steps {
if step.Kind != "script" || strings.TrimSpace(step.Script) == "" {
continue
}
if body, ok := reg.Script(def.ID, step.Script); ok {
out[step.Script] = body
}
}
return out
}
// ClaimConversation 抢占会话槽位。忙则返回 ErrConversationBusy,此时调用方不应写消息。
func (m *Manager) ClaimConversation(conversationID string) error {
return m.claimConversation(conversationID)
}
// ReleaseConversation 释放已抢到的槽位。启动失败时调用;重复释放无效果。
func (m *Manager) ReleaseConversation(conversationID string) {
m.releaseConversation(conversationID)
}
// StartClaimedConversationRun 在调用方已经抢到槽位之后启动回合。
// CreateRun 失败时释放槽位;成功后由 execute 在终态释放。
func (m *Manager) StartClaimedConversationRun(conv core.Conversation, userMessage core.Message, attachments []core.ParsedDocument, inputs []WorkspaceFile) (core.Run, error) {
return m.startClaimedRun(conv, userMessage, attachments, inputs, nil)
}
// StartClaimedWorkflowRun 在调用方已经抢到槽位之后启动工作流回合。
func (m *Manager) StartClaimedWorkflowRun(conv core.Conversation, userMessage core.Message, def core.WorkflowDef, slots map[string]string, missing []workflow.SlotQuestion) (core.Run, error) {
copiedSlots := map[string]string{}
for k, v := range slots {
copiedSlots[k] = v
}
launch := &workflow.Launch{
Def: workflow.Clone(def),
Slots: copiedSlots,
Missing: append([]workflow.SlotQuestion(nil), missing...),
Scripts: workflowScriptBodies(m.workflows, def),
}
return m.startClaimedRun(conv, userMessage, nil, nil, launch)
}
func (m *Manager) claimConversation(conversationID string) error {
m.convMu.Lock()
defer m.convMu.Unlock()
if m.convActive == nil {
m.convActive = map[string]bool{}
}
if m.convActive[conversationID] {
return ErrConversationBusy
}
m.convActive[conversationID] = true
return nil
}
func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Message, attachments []core.ParsedDocument, inputs []WorkspaceFile, launch *workflow.Launch) (core.Run, error) {
prof, _ := m.profiles.Get(conv.Owner, conv.ProfileID)
eff := m.settings.Effective(conv.Owner, conv.ProjectID)
run := core.Run{
ID: store.NewID("run"), ConversationID: conv.ID, ProjectID: conv.ProjectID, Owner: conv.Owner,
Kind: core.RunConversation, ProfileID: prof.ID, Status: core.RunPending, Phase: core.PhaseThinking,
}
if err := m.st.CreateRun(&run); err != nil {
m.releaseConversation(conv.ID)
return run, err
}
ctx, cancel := context.WithCancel(context.Background())
h := &runHandle{cancel: cancel, hitlCh: make(chan hitlAnswer, 1), started: time.Now()}
m.mu.Lock()
m.active[run.ID] = h
m.mu.Unlock()
runner := selectConversationRunner(m, eff, launch)
inputPaths, _ := m.stageInputs(conv, eff, inputs)
rc := &runCtx{mgr: m, run: run, prof: prof, eff: eff, ctx: ctx, h: h, userMessage: userMessage, attachments: attachments, inputPaths: inputPaths, launch: launch}
go m.execute(rc, runner)
return run, nil
}
// releaseConversation 释放会话在途槽位(run 终态/启动失败时)。
func (m *Manager) releaseConversation(conversationID string) {
m.convMu.Lock()
delete(m.convActive, conversationID)
m.convMu.Unlock()
}
// execute 运行至终态并收敛:状态、usage、助手消息、收尾事件。
func (m *Manager) execute(rc *runCtx, runner runtime.Runner) {
defer func() {
m.releaseConversation(rc.run.ConversationID)
m.mu.Lock()
delete(m.active, rc.run.ID)
m.mu.Unlock()
m.deliverNotices(rc.run.ConversationID)
}()
m.setRunStatus(&rc.run, core.RunRunning, core.PhaseThinking)
m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunPhase, core.RunPhasePayload{Phase: core.PhaseThinking})
execErr := func() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("runner panic: %v", r)
}
}()
return runner.Execute(rc)
}()
usage := core.Usage{DurationMs: float64(time.Since(rc.h.started).Milliseconds())}
if execErr != nil {
if rc.ctx.Err() != nil {
m.finishRun(rc, core.RunCancelled, usage, &core.ErrorPart{Code: "run.cancelled", Message: "已取消"})
return
}
m.finishRun(rc, core.RunFailed, usage, &core.ErrorPart{Code: "run.failed", Message: execErr.Error()})
return
}
m.finishRun(rc, core.RunCompleted, usage, nil)
}
func (m *Manager) finishRun(rc *runCtx, status core.RunStatus, usage core.Usage, errPart *core.ErrorPart) {
m.mu.Lock()
usage.PromptTokens = rc.h.promptTokens
usage.CompletionTokens = rc.h.completionTokens
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
m.mu.Unlock()
if m.shuttingDown.Load() && status == core.RunCancelled {
status = core.RunInterrupted
errPart = &core.ErrorPart{Code: "run.interrupted", Message: "进程停机,运行已中断"}
}
now := time.Now().UTC()
rc.run.EndedAt = &now
rc.run.Status = status
rc.run.Phase = core.PhaseDone
rc.run.Usage = &usage
rc.run.Error = errPart
// 终态权威消息树与 run 状态同一次提交。
m.mu.Lock()
parts := append([]core.Part{}, rc.h.assistant...)
m.mu.Unlock()
if errPart != nil {
parts = append(parts, core.Part{Type: core.PartError, Err: errPart})
}
var msg *core.Message
if len(parts) > 0 {
msg = &core.Message{ConversationID: rc.run.ConversationID, RunID: rc.run.ID, Role: core.RoleAssistant, Parts: parts}
}
updated, err := m.st.SettleRun(&rc.run, msg)
if err != nil {
log.Printf("[runmgr] settle run %s: %v", rc.run.ID, err)
m.failSettle(rc, err)
return
}
if m.OnUsage != nil && usage.TotalTokens > 0 {
m.OnUsage(rc.run.Owner, usage.TotalTokens)
}
if msg != nil {
// 消息树权威已落库;hitl 摘要部件的卡片状态由前端对账收敛。
// submit_card 产物卡回填锚点:卡→消息互定位(D32 方向补全)。
m.mu.Lock()
cardIDs := append([]core.ID{}, rc.h.submittedCards...)
m.mu.Unlock()
if m.cards != nil {
for _, id := range cardIDs {
if card, err := m.cards.Get(rc.run.Owner, string(id)); err == nil {
card.AnchorMessageID = msg.ID
_ = m.cards.Update(rc.run.Owner, card)
}
}
}
}
// 流式卡兜底(D45):run 终态时仍有未 commit 的流式卡——按 run 结果置
// done/failed,tone 标注「未正常收尾」;SetStatus 恒发布,收件箱可见终态。
m.mu.Lock()
streamID := rc.h.streamCardID
rc.h.streamCardID = ""
m.mu.Unlock()
if streamID != "" && m.cards != nil {
st := core.CardDone
if status != core.RunCompleted {
st = core.CardFailed
}
if card, err := m.cards.SetStatus(rc.run.Owner, string(streamID), st, "warn", "未正常收尾"); err == nil {
m.mu.Lock()
rc.h.submittedCards = append(rc.h.submittedCards, card.ID)
m.mu.Unlock()
}
}
// 停机对账已经发出 run.failed 时,这里只补消息,不再发第二次。
if !updated {
return
}
switch status {
case core.RunCompleted:
usagePayload := core.RunCompletedPayload{Usage: usage}
// 上下文组装观测随终态下发(4.10/4.17:构成与裁剪可解释)。
m.mu.Lock()
if rc.h.ctxObs != nil {
usagePayload.Context = rc.h.ctxObs
}
m.mu.Unlock()
m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunCompleted, usagePayload)
case core.RunFailed:
code, message := "run.failed", ""
if errPart != nil {
code, message = errPart.Code, errPart.Message
}
m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunFailed, core.RunFailedPayload{Code: code, Message: message})
case core.RunInterrupted:
code, message := "run.interrupted", "进程停机,运行已中断"
if errPart != nil && errPart.Code != "" {
code, message = errPart.Code, errPart.Message
}
m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunFailed, core.RunFailedPayload{Code: code, Message: message})
}
}
// failSettle 在终态提交失败后另起一次写入,把 run 标成 failed 并发出 run.failed。
func (m *Manager) failSettle(rc *runCtx, cause error) {
now := time.Now().UTC()
rc.run.EndedAt = &now
rc.run.Status = core.RunFailed
rc.run.Phase = core.PhaseDone
rc.run.Error = &core.ErrorPart{Code: "run.settle_failed", Message: cause.Error()}
if err := m.st.UpdateRun(&rc.run); err != nil {
log.Printf("[runmgr] mark settle failure %s: %v", rc.run.ID, err)
return
}
m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunFailed, core.RunFailedPayload{Code: "run.settle_failed", Message: cause.Error()})
}
// setRunStatus 更新并广播 phase(状态不单独出事件,phase 事件携带)。
func (m *Manager) setRunStatus(r *core.Run, status core.RunStatus, phase core.RunPhase) {
r.Status = status
r.Phase = phase
m.st.UpdateRun(r)
}
// emit 经批处理器持久化事件并广播;返回 seq(缓冲中的增量返回 0)。
func (m *Manager) emit(conversationID, runID string, typ core.StreamEventType, payload any) (int64, error) {
return m.events.emit(conversationID, runID, typ, payload)
}
// CancelRun 取消 run 及其子 run(ctx 级联)。
func (m *Manager) CancelRun(runID string) error {
r, err := m.st.GetRunByID(runID)
if err != nil {
return err
}
if r.Status == core.RunCompleted || r.Status == core.RunFailed || r.Status == core.RunCancelled {
return fmt.Errorf("runmgr: run %s already terminal (%s)", runID, r.Status)
}
// 取消祖先句柄(子 run 派生自父 ctx,天然级联)。
rootID := r.ParentRunID
if rootID == "" {
rootID = r.ID
}
m.mu.Lock()
if h, ok := m.active[rootID]; ok {
h.cancel()
// 唤醒可能在等 HITL 的执行。
select {
case h.hitlCh <- hitlAnswer{err: context.Canceled}:
default:
}
}
if h, ok := m.active[r.ID]; ok && r.ID != rootID {
h.cancel()
select {
case h.hitlCh <- hitlAnswer{err: context.Canceled}:
default:
}
}
m.mu.Unlock()
return nil
}
// ---- HITL 解析(三分支路由,4.5)----
// ResolveHITL 处理用户对卡片的响应:
// 1. 选项命中 → 回填挂起 run;
// 2. 自由输入以 "/" 开头(命令)→ 取消挂起 run,返回转轨标志由 server 层发送新消息;
// 3. 其他自由输入 → 作为澄清回答回填。
func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (pivotText string, pivoted bool, err error) {
card, err := m.st.GetHITLCard(owner, cardID)
if err != nil {
return "", false, err
}
if card.Resolved {
return "", false, fmt.Errorf("runmgr: card %s already resolved", cardID)
}
run, err := m.st.GetRunByID(card.RunID)
if err != nil {
return "", false, err
}
kind := "choice"
answer := ""
if resp.ChoiceID != "" {
for _, c := range card.Choices {
if c.ID == resp.ChoiceID {
answer = c.Label
break
}
}
} else if resp.FreeText != "" {
kind = "free"
answer = resp.FreeText
trimmed := strings.TrimSpace(resp.FreeText)
// 转轨判定(两源):/ 命令,或意图闸门命中其他已注册意图。
pivot := false
pivotReason := ""
if strings.HasPrefix(trimmed, "/") {
pivot, pivotReason = true, "command"
} else if m.gate != nil {
if branch, topID := m.gate.Match(context.Background(), trimmed); branch == "matched" {
pivot, pivotReason = true, "intent:"+topID
}
}
if pivot {
_ = m.CancelRun(run.ID)
m.st.ResolveHITLCard(owner, cardID, core.HITLResolvedPayload{CardID: core.ID(cardID), Kind: "cancelled", Answer: answer})
m.emit(run.ConversationID, run.ID, core.EvHITLResolved, core.HITLResolvedPayload{CardID: core.ID(cardID), Kind: "cancelled", Answer: answer})
if m.cards != nil {
_ = m.cards.ResolveByRef(owner, "hitl:"+cardID, "", answer)
}
_ = pivotReason
return trimmed, true, nil
}
}
resolution := core.HITLResolvedPayload{CardID: core.ID(cardID), Answer: answer, Kind: kind}
if err := m.st.ResolveHITLCard(owner, cardID, resolution); err != nil {
return "", false, err
}
m.emit(run.ConversationID, run.ID, core.EvHITLResolved, resolution)
// 投影卡同步销项(双现;幂等)。
if m.cards != nil {
_ = m.cards.ResolveByRef(owner, "hitl:"+cardID, resp.ChoiceID, answer)
}
m.mu.Lock()
h, ok := m.active[run.ID]
m.mu.Unlock()
if !ok {
return "", false, fmt.Errorf("runmgr: run %s not active (may have ended)", run.ID)
}
select {
case h.hitlCh <- hitlAnswer{resp: resp}:
default:
return "", false, fmt.Errorf("runmgr: hitl channel busy")
}
// 恢复运行状态。
if run.Status == core.RunWaitingHITL {
run.Status = core.RunRunning
m.st.UpdateRun(&run)
}
return "", false, nil
}
// ---- 子代理(任务树)----
func (m *Manager) spawnSubagent(parentCtx context.Context, parent core.Run, agentID, name, task, hint string) (core.AgentResultPayload, error) {
rc, runner, err := m.startChild(parentCtx, parent, agentID, name, task, hint, false)
if err != nil {
return core.AgentResultPayload{}, err
}
return m.finishChild(rc, runner, true)
}
// startChild 做深度检查、建 run、发 spawn 事件。detach 时子 run 使用独立 ctx,
// 父 run 结束不会取消它;cancel 仍登记在 active 里,CancelRun 和停机中断照常生效。
func (m *Manager) startChild(parentCtx context.Context, parent core.Run, agentID, name, task, hint string, detach bool) (*runCtx, runtime.Runner, error) {
if parent.Kind == core.RunSubagent {
return nil, nil, fmt.Errorf("子代理不能再派发子代理")
}
if strings.TrimSpace(agentID) == "" {
agentID = "agent.sub.general"
}
child := core.Run{
ID: store.NewID("run"), ParentRunID: parent.ID, ConversationID: parent.ConversationID,
ProjectID: parent.ProjectID, Owner: parent.Owner, Kind: core.RunSubagent, ProfileID: parent.ProfileID,
Status: core.RunPending, Phase: core.PhaseThinking, AgentName: name,
}
if err := m.st.CreateRun(&child); err != nil {
return nil, nil, err
}
m.emit(parent.ConversationID, parent.ID, core.EvAgentSpawn, core.AgentSpawnPayload{AgentRun: child.ID, Name: name, Task: task})
base := parentCtx
if detach {
base = context.Background()
}
ctx, cancel := context.WithCancel(base)
h := &runHandle{cancel: cancel, hitlCh: make(chan hitlAnswer, 1), started: time.Now()}
m.mu.Lock()
m.active[child.ID] = h
m.mu.Unlock()
prof, _ := m.profiles.Get(parent.Owner, parent.ProfileID)
eff := m.settings.Effective(parent.Owner, parent.ProjectID)
rc := &runCtx{mgr: m, run: child, prof: prof, eff: eff, ctx: ctx, h: h, subTask: task, subHint: hint, subAgent: agentID}
m.setRunStatus(&rc.run, core.RunRunning, core.PhaseActing)
runner := m.subRunner
if eff.GetBool("runtime.mock", false) || runner == nil {
runner = m.mockSub
}
return rc, runner, nil
}
// finishChild 执行子 run、落库并汇总。emitResult 为假时不发 agent.result,
// 留给后台投递成功之后再发。
func (m *Manager) finishChild(rc *runCtx, runner runtime.Runner, emitResult bool) (core.AgentResultPayload, error) {
execErr := func() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("subagent panic: %v", r)
}
}()
return runner.Execute(rc)
}()
usage := core.Usage{DurationMs: float64(time.Since(rc.h.started).Milliseconds())}
status := core.RunCompleted
summary := "完成"
payload := core.AgentResultPayload{AgentRun: rc.run.ID, Status: status, Summary: summary}
if execErr != nil {
if rc.ctx.Err() != nil {
status = core.RunCancelled
payload.Status = status
payload.Summary = "已取消"
} else {
status = core.RunFailed
payload.Status = status
payload.Summary = execErr.Error()
}
}
now := time.Now().UTC()
child := rc.run
child.EndedAt = &now
child.Status = status
child.Phase = core.PhaseDone
child.Usage = &usage
m.st.UpdateRun(&child)
if emitResult {
m.emit(child.ConversationID, child.ID, core.EvAgentResult, payload)
}
m.mu.Lock()
subParts := append([]core.Part{}, rc.h.assistant...)
m.mu.Unlock()
fullText := partsText(subParts)
if len(subParts) > 0 {
_ = m.st.AppendMessage(&core.Message{ConversationID: child.ConversationID, RunID: child.ID, Role: core.RoleAssistant, Parts: subParts})
}
if fullText != "" {
payload.Text = fullText
if payload.Summary == "" || payload.Summary == "完成" {
payload.Summary = truncate(fullText, 160)
}
}
if line := artifactSummary(subParts); line != "" && status == core.RunCompleted {
payload.Summary = strings.TrimSpace(payload.Summary + "\n" + line)
}
m.mu.Lock()
delete(m.active, child.ID)
m.mu.Unlock()
if status != core.RunCompleted {
return payload, fmt.Errorf("subagent %s: %s", child.AgentName, payload.Summary)
}
return payload, nil
}
func (m *Manager) spawnBackground(parent core.Run, agentID, name, task, hint string) (core.ID, error) {
if parent.Kind == core.RunSubagent {
return "", fmt.Errorf("子代理不能再派发子代理")
}
m.noticeMu.Lock()
if m.bgInFlight == nil {
m.bgInFlight = map[string]int{}
}
if m.bgInFlight[parent.ConversationID] >= maxBackgroundChildren {
m.noticeMu.Unlock()
return "", fmt.Errorf("本会话已有 %d 个后台子代理在运行", maxBackgroundChildren)
}
m.bgInFlight[parent.ConversationID]++
m.noticeMu.Unlock()
rc, runner, err := m.startChild(context.Background(), parent, agentID, name, task, hint, true)
if err != nil {
m.decBackground(parent.ConversationID)
return "", err
}
go func() {
defer m.decBackground(parent.ConversationID)
payload, _ := m.finishChild(rc, runner, false)
m.enqueueNotice(parent.ConversationID, bgNotice{owner: parent.Owner, name: name, payload: payload})
m.deliverNotices(parent.ConversationID)
}()
return rc.run.ID, nil
}
func (m *Manager) decBackground(convID string) {
m.noticeMu.Lock()
defer m.noticeMu.Unlock()
if m.bgInFlight == nil {
return
}
if m.bgInFlight[convID] > 0 {
m.bgInFlight[convID]--
}
}
func (m *Manager) enqueueNotice(convID string, n bgNotice) {
m.noticeMu.Lock()
defer m.noticeMu.Unlock()
if m.notices == nil {
m.notices = map[string][]bgNotice{}
}
m.notices[convID] = append(m.notices[convID], n)
}
// deliverNotices 在会话空闲时把全部待投递结果合并成一条 system 消息,并启动新的父回合。
// 会话忙、或启动时发现忙,就把结果放回队列。
func (m *Manager) deliverNotices(convID string) {
m.convMu.Lock()
busy := m.convActive != nil && m.convActive[convID]
m.convMu.Unlock()
if busy {
return
}
m.noticeMu.Lock()
if m.delivering == nil {
m.delivering = map[string]bool{}
}
if m.delivering[convID] {
m.noticeMu.Unlock()
return
}
items := append([]bgNotice{}, m.notices[convID]...)
if len(items) == 0 {
m.noticeMu.Unlock()
return
}
delete(m.notices, convID)
m.delivering[convID] = true
m.noticeMu.Unlock()
defer func() {
m.noticeMu.Lock()
delete(m.delivering, convID)
more := len(m.notices[convID]) > 0
m.noticeMu.Unlock()
if more {
m.deliverNotices(convID)
}
}()
if err := m.startNoticeRun(convID, items); err != nil {
m.noticeMu.Lock()
if m.notices == nil {
m.notices = map[string][]bgNotice{}
}
m.notices[convID] = append(items, m.notices[convID]...)
m.noticeMu.Unlock()
}
}
func (m *Manager) startNoticeRun(convID string, items []bgNotice) error {
if len(items) == 0 {
return nil
}
if err := m.claimConversation(convID); err != nil {
return err
}
owner := items[0].owner
conv, err := m.st.GetConversation(owner, convID)
if err != nil {
m.releaseConversation(convID)
return err
}
var b strings.Builder
b.WriteString("<system-reminder>\n")
for _, n := range items {
fmt.Fprintf(&b, "后台子代理「%s」已结束。\n状态:%s\n%s\n", n.name, n.payload.Status, strings.TrimSpace(n.payload.Summary))
}
b.WriteString("</system-reminder>")
msg := core.Message{
ConversationID: convID, Role: core.RoleSystem,
Parts: []core.Part{{Type: core.PartText, Text: b.String()}},
}
if err := m.st.AppendMessage(&msg); err != nil {
m.releaseConversation(convID)
return err
}
if _, err := m.startClaimedRun(conv, msg, nil, nil, nil); err != nil {
m.releaseConversation(convID)
return err
}
for _, n := range items {
m.emit(convID, n.payload.AgentRun, core.EvAgentResult, n.payload)
}
return nil
}
// InterruptRunning 服务停机前把在途 run 收成 interrupted(4.2 恢复策略)。
// 与启动对账共用 sealRunInterrupted,终态形状一致。
func (m *Manager) InterruptRunning() {
m.shuttingDown.Store(true)
m.mu.Lock()
ids := make([]string, 0, len(m.active))
for id := range m.active {
ids = append(ids, id)
}
handles := make([]*runHandle, 0, len(ids))
for _, id := range ids {
if h, ok := m.active[id]; ok {
handles = append(handles, h)
}
}
m.mu.Unlock()
for _, h := range handles {
h.cancel()
}
for _, id := range ids {
m.sealRunInterrupted(id)
}
}
// ReconcileInterrupted 在开始监听之前调用。
// SQLite 档扫全部非终态 run。PG 档只扫本实例,以及升级前 instance_id 为空的行。
func (m *Manager) ReconcileInterrupted() error {
all := m.st.Dialect() != "postgres"
runs, err := m.st.ListRecoverableRuns(m.instanceID, all)
if err != nil {
return err
}
for _, r := range runs {
m.sealRunInterrupted(r.ID)
}
return nil
}
// sealRunInterrupted 条件更新成功才关 HITL 并发事件。并发调用只有一个赢家。
func (m *Manager) sealRunInterrupted(runID string) {
ok, err := m.st.SealInterrupted(runID)
if err != nil || !ok {
return
}
r, err := m.st.GetRunByID(runID)
if err != nil {
return
}
m.closePendingHITL(r)
code, message := "run.interrupted", "进程重启或停机,运行已中断"
if r.Error != nil && r.Error.Code != "" {
code, message = r.Error.Code, r.Error.Message
}
m.emit(r.ConversationID, r.ID, core.EvRunFailed, core.RunFailedPayload{Code: code, Message: message})
}
func (m *Manager) closePendingHITL(r core.Run) {
cards, err := m.st.ActiveHITLCardsByRun(r.ID)
if err != nil {
return
}
for _, c := range cards {
res := core.HITLResolvedPayload{CardID: c.ID, Kind: "cancelled"}
if err := m.st.ResolveHITLCard(r.Owner, string(c.ID), res); err != nil {
continue
}
m.emit(r.ConversationID, r.ID, core.EvHITLResolved, res)
}
}
func partsText(parts []core.Part) string {
var sb strings.Builder
for _, p := range parts {
if p.Type == core.PartText {
sb.WriteString(p.Text)
sb.WriteString("\n")
}
}
return strings.TrimSpace(sb.String())
}
func artifactSummary(parts []core.Part) string {
var bits []string
seen := map[string]bool{}
for _, p := range parts {
if p.Type != core.PartArtifactRef || p.Artifact == nil || p.Artifact.ArtifactID == "" || seen[p.Artifact.ArtifactID] {
continue
}
seen[p.Artifact.ArtifactID] = true
name := p.Artifact.Name
if name == "" {
name = p.Artifact.ArtifactID
}
bits = append(bits, "工件:"+p.Artifact.ArtifactID+" "+name)
}
return strings.Join(bits, ";")
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}