ci / vet-and-test (pull_request) Successful in 43s
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>
1512 lines
51 KiB
Go
1512 lines
51 KiB
Go
// 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] + "…"
|
||
}
|