Files
Agent-System-Backend/internal/runmgr/runmgr.go
T
weichen adcd8fefd2 Fence U3 account writes and stabilize directory cursors
Why: Account disable could be followed by late Backend business writes, API key creation could implicitly create users, account bootstrap locks could invert, and directory cursors were process-local or leaked out-of-scope changes.

Evidence: Local go test ./... and isolated PostgreSQL package suites passed on the staged candidate; exact committed-head SELF replay and stage 6 human review remain pending.

Impact: Adds final account transactions and external-effect checks across HTTP, Host, Bus and run manager; tightens API key issuance and persists versioned cursor signing. Delegated RDM rename still needs the separately proposed real-operator contract, so A1 and stage 6 are not complete.

Refs: Documents U3 r1, approved A1-A4 amendment fb3a821, pending delegated-projection amendment 5783940, Dev feature/iam-u3-account-directory-recovery.
2026-10-14 07:33:54 +00:00

2844 lines
103 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"
"encoding/json"
"errors"
"fmt"
"log"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"unicode/utf8"
nruntime "agent-native/runtime"
"agent-native/runtime/filestate"
agentllm "agent-native/runtime/llm"
agentpolicy "agent-native/runtime/policy"
"agent-native/runtime/subagent"
"agent-system-backend/internal/bus"
"agent-system-backend/internal/cardsvc"
"agent-system-backend/internal/ingest"
"agent-system-backend/internal/intentgate"
"agent-system-backend/internal/profiles"
"agent-system-backend/internal/route"
"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.CompiledWorkflowRunner
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 全文)
// sharedKnowledgeAllow EXT-13:回合内检索的共享授权谓词(请求 owner × 知识所属
// projectID);nil = 未注册(现状:仅本人两路,不触碰共享通道)。
sharedKnowledgeAllow func(owner string, projectID core.ID) bool
knowledgeResourceAllow func(owner string, doc core.KnowledgeDoc) bool
knowledgeProjectScope func(context.Context, string, core.ID) (core.ID, error)
projectWorkAuthorization func(context.Context, string, core.ID) (bool, error)
accountEffectiveAuthorization func(context.Context, string) (bool, error)
accountAllowImplicitLocal bool
// commands EXT-14:命令即工具的投影与分发源(nil = 未接线,命令不进工具清单)。
commands CommandSource
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
adm *admissionGate // 执行许可门(U3:预算默认关闭时零开销)
instanceTag string // 执行身份(U5:多实例互异;空=runmgr:addr)
addr string // 监听地址(身份退化用)
execMu sync.Mutex // 执行所有权(U5:DB 租约的本地代次登记)
execEpoch map[string]int64 // conversationID → 本实例当前 epoch
instanceID string
shuttingDown atomic.Bool
// 后台子代理的完成通知只留在内存里。重启后未投递的通知丢失。
noticeMu sync.Mutex
notices map[string][]bgNotice
bgInFlight map[string]int
delivering map[string]bool
// 材料整理内容键注册表(附件即保证):会话 → 活跃材料键。卡片 KV 是
// 重启后的持久兜底,这里只是热路径的快速去重。
materialMu sync.Mutex
materialActive map[string]map[string]bool
// 后台排队(I3):会话满 3 时的系统派发任务先建卡(排队中)入队,
// 槽位释放按 FIFO 启动。模型 delegate_task 仍硬拒绝,不入队。
bgQueue map[string][]bgQueueItem
allowMu sync.Mutex
sessionAllow map[string]map[string]bool
// genuiAuto:Generative UI 自动建议闸门(nil = 未接线;默认关闭行为)。
// 仅由任务阶段/材料工件语义事件调用;present/surface/action 不得接入。
genuiAuto GenUIAutoTrigger
}
// GenUIAutoTrigger 自动建议页面闸门(由 genui.Auto 实现)。
type GenUIAutoTrigger interface {
Try(owner, convID, reason string) (ok bool, skip string)
}
const maxBackgroundChildren = 3
// bgQueueItem 一个等待槽位的系统派发后台任务(建卡先行,子 run 启动延后)。
type bgQueueItem struct {
parent core.Run
opts bgSpawnOpts
card core.ActivityCard
}
// bgNotice 是一条尚未投递给父会话的后台子代理结果。
type bgNotice struct {
id string // 持久化队列主键(重启重投凭据)
owner string
name string
payload core.AgentResultPayload
material bool // 材料整理任务:通知回合要衔接交付物选择(I2 消费)
}
// ErrConversationBusy 表示会话已有在途 run(HTTP 层映射 409)。
var ErrConversationBusy = fmt.Errorf("runmgr: conversation already has an in-flight run")
type runHandle struct {
owner string
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
lastPromptTokens int
lastCompletionTokens int
cacheReadTokens int
cacheWriteTokens 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{}, execEpoch: map[string]int64{},
notices: map[string][]bgNotice{}, bgInFlight: map[string]int{}, delivering: map[string]bool{},
events: newEventBuffer(st, b),
adm: newAdmissionGate(nil, 0),
}
}
// 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)
}
// EmitProjectSharedCardEvent is an internal producer-authorized structural
// publication entry, separate from the ordinary private card event emitter.
func (m *Manager) EmitProjectSharedCardEvent(owner, producer, channel, runID string, typ core.StreamEventType, payload any) error {
return m.events.emitProjectShared(owner, producer, channel, runID, typ, payload)
}
// 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
}
func (m *Manager) EmitCardEventAccountChecked(actor, conversationID, runID string, typ core.StreamEventType, payload any) error {
return m.events.emitAccountChecked(actor, conversationID, runID, typ, payload, m.accountAllowImplicitLocal)
}
func (m *Manager) appendMessageForEffectiveAccount(actor string, msg *core.Message) error {
if m.accountEffectiveAuthorization != nil {
return m.st.AppendMessageWithRequestAccountChecked(actor, m.accountAllowImplicitLocal, msg, "")
}
return m.st.AppendMessage(msg)
}
func (m *Manager) saveHITLCardForEffectiveAccount(actor string, card *core.HITLCard) error {
if m.accountEffectiveAuthorization != nil {
return m.st.SaveHITLCardAccountChecked(actor, card, m.accountAllowImplicitLocal)
}
return m.st.SaveHITLCard(actor, card)
}
func (m *Manager) resolveHITLCardForEffectiveAccount(actor, id string, resolution core.HITLResolvedPayload) error {
if m.accountEffectiveAuthorization != nil {
return m.st.ResolveHITLCardAccountChecked(actor, id, resolution, m.accountAllowImplicitLocal)
}
return m.st.ResolveHITLCard(actor, id, resolution)
}
func (m *Manager) resolveProjectedCardForEffectiveAccount(actor, ref, actionID, answer string) error {
if m.cards == nil {
return nil
}
if m.accountEffectiveAuthorization != nil {
return m.cards.ResolveByRefAccountChecked(actor, ref, actionID, answer, m.accountAllowImplicitLocal)
}
return m.cards.ResolveByRef(actor, ref, actionID, answer)
}
func (m *Manager) createCardForEffectiveAccount(actor string, card core.ActivityCard, ref string) (core.ActivityCard, error) {
if m.accountEffectiveAuthorization != nil {
return m.cards.CreateAccountChecked(actor, card, ref, m.accountAllowImplicitLocal)
}
return m.cards.Create(actor, card, ref)
}
func (m *Manager) setCardStatusForEffectiveAccount(actor, id string, status core.CardStatus, tone, note string) (core.ActivityCard, error) {
if m.accountEffectiveAuthorization != nil {
return m.cards.SetStatusAccountChecked(actor, id, status, tone, note, m.accountAllowImplicitLocal)
}
return m.cards.SetStatus(actor, id, status, tone, note)
}
func (m *Manager) updateCardForEffectiveAccount(actor string, card core.ActivityCard) error {
if m.accountEffectiveAuthorization != nil {
_, version, err := m.st.GetCardVersion(actor, card.ID)
if err != nil {
return err
}
return m.cards.UpdateVersionedAccountChecked(actor, card, version, m.accountAllowImplicitLocal)
}
return m.cards.Update(actor, card)
}
func (m *Manager) appendCardForEffectiveAccount(actor, id, text string) error {
if m.accountEffectiveAuthorization != nil {
return m.cards.AppendAccountChecked(actor, id, text, m.accountAllowImplicitLocal)
}
return m.cards.Append(actor, id, text)
}
// SetRetriever 注入混合检索(rag 管线;nil = store 全文检索)。
func (m *Manager) SetRetriever(r core.Retriever) { m.retriever = r }
// SetSharedKnowledgeAllow 注入回合内共享检索谓词(EXT-13;组合根在装载扩展后
// 以同一 EXT-8 适配器槽接线)。nil = 现状。
func (m *Manager) SetSharedKnowledgeAllow(fn func(owner string, projectID core.ID) bool) {
m.sharedKnowledgeAllow = fn
}
// SetKnowledgeResourceAllow makes resource-level authorization authoritative for
// model context, including owned rows. The callback receives stored identities.
func (m *Manager) SetKnowledgeResourceAllow(fn func(string, core.KnowledgeDoc) bool) {
m.knowledgeResourceAllow = fn
}
// SetProjectWorkAuthorization is the live domain guard for starting runs and
// executing tools. Nil preserves the ordinary unregistered host behavior.
func (m *Manager) SetProjectWorkAuthorization(fn func(context.Context, string, core.ID) (bool, error)) {
m.projectWorkAuthorization = fn
}
// SetAccountEffectiveAuthorization binds the current account-state guard. It is
// independent of the optional domain project provider.
func (m *Manager) SetAccountEffectiveAuthorization(fn func(context.Context, string) (bool, error), allowImplicitLocal ...bool) {
m.accountEffectiveAuthorization = fn
m.events.accountGuardEnabled = fn != nil
if len(allowImplicitLocal) > 0 {
m.accountAllowImplicitLocal = allowImplicitLocal[0]
m.events.allowImplicitLocal = allowImplicitLocal[0]
}
}
func (m *Manager) authorizeAccountEffective(ctx context.Context, owner string) error {
if m.accountEffectiveAuthorization == nil {
return nil
}
ok, err := m.accountEffectiveAuthorization(ctx, owner)
if err != nil {
m.cancelActiveOwnerRuns(owner)
return err
}
if !ok {
m.cancelActiveOwnerRuns(owner)
return store.ErrNotFound
}
return nil
}
func (m *Manager) cancelActiveOwnerRuns(owner string) {
m.mu.Lock()
var cancels []context.CancelFunc
for _, h := range m.active {
if h.owner == owner && h.cancel != nil {
cancels = append(cancels, h.cancel)
}
}
m.mu.Unlock()
for _, cancel := range cancels {
cancel()
}
}
func (m *Manager) authorizeProjectWork(ctx context.Context, owner string, id core.ID) error {
if m.projectWorkAuthorization == nil {
return nil
}
ok, err := m.projectWorkAuthorization(ctx, owner, id)
if err != nil {
return err
}
if !ok {
return store.ErrNotFound
}
return nil
}
func (m *Manager) authorizeRunWork(ctx context.Context, owner string, id core.ID) error {
if err := m.authorizeAccountEffective(ctx, owner); err != nil {
return err
}
return m.authorizeProjectWork(ctx, owner, id)
}
// SetKnowledgeProjectScope binds host project filters to the provider's trusted
// canonical domain metadata without widening the current conversation scope.
func (m *Manager) SetKnowledgeProjectScope(fn func(context.Context, string, core.ID) (core.ID, error)) {
m.knowledgeProjectScope = fn
}
// SetCommandSource 注入命令即工具源(EXT-14;组合根以命令总线适配)。nil = 现状。
func (m *Manager) SetCommandSource(src CommandSource) { m.commands = src }
// SetIndexer 注入检索索引入口(分块+全文+可选向量;nil = 整文档单块)。
func (m *Manager) SetIndexer(i core.DocumentIndexer) { m.indexer = i }
// SetExecutionBudgetFn 注入执行预算读取(U3,R8):fn 返回当前预算(全零=
// 不限,本地档默认);queueMax 为待执行队列上限(<=0 走默认 200)。
func (m *Manager) SetExecutionBudgetFn(fn func() store.ExecutionBudget, queueMax int) {
if m.adm == nil {
m.adm = newAdmissionGate(fn, queueMax) // 直接构造的 Manager(测试)
return
}
m.adm.mu.Lock()
m.adm.budgetFn = fn
if queueMax > 0 {
m.adm.queueMax = queueMax
}
m.adm.mu.Unlock()
}
// QueueFull 非阻塞探测待执行队列是否已满(API 边缘 429 拒绝,不落库)。
func (m *Manager) QueueFull() bool { return m.adm.QueueFull() }
// QueueDepth 当前排队数(诊断)。
func (m *Manager) QueueDepth() int { return m.adm.QueueDepth() }
// SetWorkspaceRoot 注入 fs 预留能力(Hidden 工具族)的沙箱根目录。
// 实际会话沙箱为 <root>/<owner>/<conversationID>/;空值=未配置(工具执行报错)。
func (m *Manager) SetWorkspaceRoot(dir string) { m.workspaceRoot = dir }
// CommandSource EXT-14:命令总线暴露给回合执行的两个面——工具投影清单与
// 经命令总的分发(同审计信封与 HITL 收口)。runmgr 只认本包视图类型,
// 组合根以 cmdbus.Bus 适配注入(app.busAdapter 负责转换),runmgr 不依赖总线包。
type CommandSource interface {
CommandTools() []CommandTool
// CommandToolsFor 按 owner 过滤工具投影(U3:禁用即不注入能力)。
CommandToolsFor(owner string) []CommandTool
DispatchCommand(ctx context.Context, req CommandRequest) CommandResponse
}
// CommandTool EXT-14 工具投影视图:组合根从命令总线的投影清单转换注入。
type CommandTool struct {
ID string
Description string
Params json.RawMessage
Risk string
}
// CommandRequest 一次命令工具分发请求(runmgr 固定以 Entrance=agent 发起,
// Actor/Owner 绑定会话 owner、带会话上下文)。
type CommandRequest struct {
ID string
Params map[string]any
Entrance string
Actor string
Owner string
ConversationID string
}
// CommandResponse 分发结果视图:回执状态/原因 + 负载 + 是否挂起确认卡。
type CommandResponse struct {
State string
Reason string
Payload any
Card bool
}
// sharedRetriever EXT-13:检索器具备共享通道(rag.Pipeline.RetrieveShared 同形)。
type sharedRetriever interface {
RetrieveShared(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error)
}
// ---- 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
subModel string // 系统派发覆盖("chat" = 子代理用对话同档;空 = 按 spec)
launch *workflow.Launch
files *filestate.Tracker
permissionMode string
// 材料整理(附件即保证):预启动说明注入第 0 回合;ensureMaterial 兜底幂等。
materialNote string
materialEnsured bool
}
// Surfaces 返回当前会话最近保存的生成页面(U3/R6:ctxasm 语义投影回灌;
// 最近 5 页、新→旧;读路径不受启禁影响——禁用挡写入,不丢历史)。
func (c *runCtx) Surfaces() []core.SurfaceSnapshot {
if c.mgr.st == nil || c.run.ConversationID == "" || c.run.Owner == "" {
return nil
}
metas, err := c.mgr.st.ListSurfaces(string(c.run.Owner), string(c.run.ConversationID))
if err != nil || len(metas) == 0 {
return nil
}
if len(metas) > 5 {
metas = metas[:5]
}
out := make([]core.SurfaceSnapshot, 0, len(metas))
for _, m := range metas {
if snap, err := c.mgr.st.GetSurfaceSnapshot(string(c.run.Owner), string(m.SurfaceID), 0); err == nil {
out = append(out, snap)
}
}
return out
}
// 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。
// 计费拆分(N3 WP6):Last* 覆盖(最近一次请求)、Cache* 累计。
func (c *runCtx) AddUsage(u core.Usage) {
c.mgr.mu.Lock()
c.h.promptTokens += u.PromptTokens
c.h.completionTokens += u.CompletionTokens
c.h.lastPromptTokens = u.LastPromptTokens
c.h.lastCompletionTokens = u.LastCompletionTokens
c.h.cacheReadTokens += u.CacheReadTokens
c.h.cacheWriteTokens += u.CacheWriteTokens
c.mgr.mu.Unlock()
}
func (c *runCtx) Subtask() string { return c.subTask }
// Tools 模型可见工具清单:Hidden 工具(fs 预留能力)在 runtime.fsTools 未开时过滤
// (与 /api/tools 列表同门禁;registry.Execute 另有执行侧 fail-closed)。
func conversationMode(m *Manager, owner, id string) string {
if m == nil || m.st == nil || id == "" {
return ""
}
conv, err := m.st.GetConversation(owner, id)
if err != nil {
return ""
}
return conv.PermissionMode
}
func (c *runCtx) PermissionMode() string { return c.permissionMode }
func (c *runCtx) ExitPlanMode() error {
if c.mgr == nil || c.mgr.st == nil {
c.permissionMode = ""
return nil
}
var err error
if c.mgr.accountEffectiveAuthorization != nil {
err = c.mgr.st.SetPermissionModeAccountChecked(c.run.Owner, c.run.ConversationID, "default", c.mgr.accountAllowImplicitLocal)
} else {
err = c.mgr.st.SetPermissionMode(c.run.Owner, c.run.ConversationID, "default")
}
if err == nil {
c.permissionMode = ""
}
return err
}
func (m *Manager) rememberSessionAllow(conv, tool string) {
if m == nil || conv == "" || tool == "" {
return
}
m.allowMu.Lock()
defer m.allowMu.Unlock()
if m.sessionAllow == nil {
m.sessionAllow = map[string]map[string]bool{}
}
if m.sessionAllow[conv] == nil {
m.sessionAllow[conv] = map[string]bool{}
}
m.sessionAllow[conv][tool] = true
}
func (m *Manager) sessionAllows(conv, tool string) bool {
if m == nil {
return false
}
m.allowMu.Lock()
defer m.allowMu.Unlock()
return m.sessionAllow[conv][tool]
}
// convAllowStore 把 Manager 的会话放行表呈现为 agentpolicy.SessionAllows 端口。
// 键控保持既有口径:会话 × 工具名(同名调用不再询问),不区分参数。
type convAllowStore struct {
mgr *Manager
conv string
}
func (s convAllowStore) Remember(name, args string) {
s.mgr.rememberSessionAllow(s.conv, name)
}
func (s convAllowStore) Allowed(name, args string) bool {
return s.mgr.sessionAllows(s.conv, name)
}
// toolConfirmCard 是工具确认 HITL 卡的统一形态:ExecuteTool 的画像确认与
// 内核 PermissionAsk 端口共用,两条路径的选项与口径保持一致。
func toolConfirmCard(name, args string) core.HITLCard {
var argsPreview string
if len(args) > 0 {
argsPreview = truncate(args, 600)
}
return core.HITLCard{
Question: fmt.Sprintf("执行工具 %s?", name), Kind: core.HITLKindConfirm, Detail: argsPreview,
Choices: []core.HITLChoice{
{ID: "yes", Label: "执行", Description: "按上述参数调用", Impact: "该工具将访问外部资源并返回结果", Recommended: true},
{ID: "session", Label: "本会话始终允许", Description: "这次以及本会话后续的同名调用不再询问", Impact: "拒绝规则和计划模式仍然有效"},
{ID: "no", Label: "取消", Description: "跳过本次调用", Impact: "模型会收到调用被拒绝的说明"},
},
AllowFreeInput: false,
}
}
// PermissionAsk 把内核钩子的 ask 决策路由到 HITL 确认卡(N1 WP2 宿主侧)。
// 「本会话始终允许」沿用 ExecuteTool 的会话放行口径(会话 × 工具名)。
func (c *runCtx) PermissionAsk(ctx context.Context, call agentllm.ToolCall) nruntime.AskVerdict {
resp, err := c.AskHITL(toolConfirmCard(call.Name, call.Arguments))
if err != nil {
return nruntime.AskVerdict{Reason: "hitl aborted: " + err.Error()}
}
switch resp.ChoiceID {
case "session":
c.mgr.rememberSessionAllow(c.run.ConversationID, call.Name)
return nruntime.AskVerdict{Allow: true}
case "yes":
return nruntime.AskVerdict{Allow: true}
default:
return nruntime.AskVerdict{Reason: "用户取消了本次工具调用"}
}
}
func (c *runCtx) Tools() []core.ToolDesc {
list := tools.FilterExecDisabled(c.mgr.registry.List())
if c.mgr.commands != nil {
// EXT-14:exposeAsTool 命令投影为工具(去重:注册表同名工具优先)。
seen := make(map[string]bool, len(list))
for _, t := range list {
seen[t.Name] = true
}
for _, ct := range c.mgr.commands.CommandToolsFor(string(c.run.Owner)) {
if seen[ct.ID] {
continue
}
list = append(list, core.ToolDesc{Name: ct.ID, Description: ct.Description,
Parameters: ct.Params, Source: core.SourcePlugin, Risk: ct.Risk})
}
}
out := make([]core.ToolDesc, 0, len(list))
fs := c.eff.GetBool("runtime.fsTools", false)
code := c.eff.GetBool("runtime.codeOrchestration", false)
for _, t := range list {
if t.Hidden && !fs {
continue
}
if t.Name == "run_tool_script" && !code {
continue
}
readOnly := t.ReadOnly || agentpolicy.TraitsFor(t.Name).ReadOnly
if !agentpolicy.Visible(c.permissionMode, t.Name, readOnly) {
continue
}
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) ContextObservation() *core.ContextObservation {
c.mgr.mu.Lock()
defer c.mgr.mu.Unlock()
return c.h.ctxObs
}
func (c *runCtx) SessionNote() string {
if c.mgr == nil || c.mgr.st == nil {
return ""
}
conv, err := c.mgr.st.GetConversation(c.run.Owner, c.run.ConversationID)
if err != nil {
return ""
}
return conv.SessionNote
}
func (c *runCtx) Injections() []runtime.ContextInjection {
if c.mgr == nil || c.mgr.st == nil {
return nil
}
list, err := c.mgr.st.ListContextEntries(c.run.Owner, c.run.ProjectID, c.run.ConversationID)
if err != nil {
return nil
}
out := make([]runtime.ContextInjection, 0, len(list))
for _, e := range list {
if !e.Enabled {
continue
}
out = append(out, runtime.ContextInjection{
Keys: e.Keys, Secondary: e.Secondary, Logic: e.Logic, Content: e.Content,
Constant: e.Constant, ScanDepth: e.ScanDepth, BudgetChars: e.BudgetChars,
})
}
return out
}
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 {
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
return err
}
_, err := c.mgr.emit(c.run.ConversationID, c.run.ID, typ, payload)
return err
}
func (c *runCtx) SetAssistantParts(parts []core.Part) error {
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
return err
}
c.mgr.mu.Lock()
c.h.assistant = parts
c.mgr.mu.Unlock()
return nil
}
func (c *runCtx) AskHITL(card core.HITLCard) (answer core.HITLResponse, err error) {
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
return core.HITLResponse{}, err
}
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.saveHITLCardForEffectiveAccount(c.run.Owner, &card); err != nil {
return core.HITLResponse{}, err
}
if err := c.mgr.setRunStatus(&c.run, core.RunWaitingHITL, c.run.Phase); err != nil {
return core.HITLResponse{}, err
}
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.createCardForEffectiveAccount(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
}
// HITL 等待让出执行许可(U3:等待审批不占执行额度;应答后重取——可能
// 排队,取消语境下重取失败由取消路径收口)。
c.mgr.adm.release(c.mgr.st, string(c.run.ID))
defer func() {
if acquireErr := c.mgr.adm.acquire(c.ctx, c.mgr.st, string(c.run.ID), c.run.Owner, c.mgr.accountEffectiveAuthorization != nil, c.mgr.accountAllowImplicitLocal); acquireErr != nil {
log.Printf("[runmgr] admission reacquire after HITL %s: %v", c.run.ID, acquireErr)
if err == nil {
err = acquireErr
}
}
// Permission can change during both confirmation and admission waiting.
// Every HITL caller receives the live decision before it resumes work.
if err == nil {
err = c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID)
}
}()
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 }
// Submodel 系统派发的模型档覆盖(材料整理用对话同档;空 = 子代理 spec 默认)。
func (c *runCtx) Submodel() string { return c.subModel }
func (c *runCtx) UpdatePlan(p core.PlanSnapshot) error {
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
return err
}
prev, _ := c.mgr.st.GetPlan(c.run.ConversationID)
p.PlanID = c.run.ConversationID
var saveErr error
if c.mgr.accountEffectiveAuthorization != nil {
saveErr = c.mgr.st.SavePlanAccountChecked(c.run.Owner, c.run.ConversationID, p, c.mgr.accountAllowImplicitLocal)
} else {
saveErr = c.mgr.st.SavePlan(c.run.ConversationID, p)
}
if err := saveErr; err != nil {
return err
}
_, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvPlanUpdate, core.PlanUpdatePayload{Plan: p})
if err == nil && planStageChanged(prev, p) {
c.mgr.maybeGenUIAuto(c.run.Owner, c.run.ConversationID, "plan_stage")
}
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 {
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
return err
}
prev, _ := c.mgr.st.GetTodos(c.run.ConversationID)
l.ConversationID = c.run.ConversationID
var saveErr error
if c.mgr.accountEffectiveAuthorization != nil {
saveErr = c.mgr.st.SaveTodosAccountChecked(c.run.Owner, c.run.ConversationID, l, c.mgr.accountAllowImplicitLocal)
} else {
saveErr = c.mgr.st.SaveTodos(c.run.ConversationID, l)
}
if err := saveErr; err != nil {
return err
}
_, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvTodoUpdate, core.TodoUpdatePayload{List: l})
if err == nil && todoStageChanged(prev, l) {
c.mgr.maybeGenUIAuto(c.run.Owner, c.run.ConversationID, "todo_stage")
}
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 err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
return core.ActivityCard{}, err
}
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.createCardForEffectiveAccount(c.run.Owner, card, "")
}
func (c *runCtx) SetCardStatus(id string, status core.CardStatus, tone, note string) error {
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
return err
}
if c.mgr.cards == nil {
return nil
}
_, err := c.mgr.setCardStatusForEffectiveAccount(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 {
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
return err
}
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.appendCardForEffectiveAccount(c.run.Owner, string(id), text)
}
// CommitStreamCard:card_commit 工具入口——活跃流式卡置 done、登记锚点回填
// 并清空活跃指针。
func (c *runCtx) CommitStreamCard() (core.ActivityCard, error) {
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
return core.ActivityCard{}, err
}
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.setCardStatusForEffectiveAccount(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) {
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
return core.Artifact{}, err
}
art := core.Artifact{ProjectID: c.run.ProjectID, Owner: c.run.Owner, Name: name, Kind: kind, MIME: mime, SourceRun: c.run.ID}
var writeErr error
if c.mgr.accountEffectiveAuthorization != nil {
writeErr = c.mgr.st.CreateArtifactAccountChecked(c.run.Owner, &art, content, c.mgr.accountAllowImplicitLocal)
} else {
writeErr = c.mgr.st.CreateArtifact(&art, content)
}
if err := writeErr; 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
}
if kind == "document" {
c.mgr.maybeGenUIAuto(c.run.Owner, c.run.ConversationID, "artifact_document")
}
// 工件文本进检索索引。二进制 Office/PDF 先经 ingest 抽出纯文本,抽到才入库;
// 抽不到则跳过,避免把 ZIP 字节当文本索引。
if kind == "document" && len(content) < 200000 {
if text := indexTextOf(name, mime, content); text != "" {
if c.mgr.accountEffectiveAuthorization != nil {
if c.mgr.indexer != nil {
checked, ok := c.mgr.indexer.(interface {
IndexDocumentAccountChecked(context.Context, core.ID, core.ID, string, string, string, string, bool) error
})
if !ok {
return art, fmt.Errorf("document indexer does not support account fencing")
}
if err := checked.IndexDocumentAccountChecked(c.ctx, c.run.Owner, c.run.ProjectID, "artifact", name, art.ID, text, c.mgr.accountAllowImplicitLocal); err != nil {
return art, err
}
} else if err := c.mgr.st.AddKnowledgeAccountChecked(string(c.run.Owner), c.mgr.accountAllowImplicitLocal, core.KnowledgeDoc{Owner: c.run.Owner, ProjectID: c.run.ProjectID, Source: "artifact", Title: name, Ref: art.ID, Chunk: text}); err != nil {
return art, err
}
} else 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.knowledgeProjectScope != nil {
id, err := c.mgr.knowledgeProjectScope(c.ctx, c.run.Owner, q.ProjectID)
if err != nil {
return nil, err
}
q.ProjectID = id
}
if c.mgr.knowledgeResourceAllow != nil {
allow := func(d core.KnowledgeDoc) bool { return c.mgr.knowledgeResourceAllow(string(c.run.Owner), d) }
if rs, ok := c.mgr.retriever.(interface {
RetrieveAuthorized(context.Context, core.ID, core.RetrievalQuery, func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error)
}); ok {
return rs.RetrieveAuthorized(c.ctx, c.run.Owner, q, allow)
}
hits, err := c.mgr.st.SearchKnowledge(c.run.Owner, q)
if err != nil {
return nil, err
}
out := []core.RetrievalHit{}
for _, h := range hits {
if allow(h.Doc) {
out = append(out, h)
}
}
shared, err := c.mgr.st.SearchKnowledgeSharedAuthorized(c.run.Owner, q, allow)
if err != nil {
return nil, err
}
out = append(out, shared...)
if len(out) > q.TopK {
out = out[:q.TopK]
}
return out, nil
}
if c.mgr.retriever != nil {
if c.mgr.sharedKnowledgeAllow != nil {
if rs, ok := c.mgr.retriever.(sharedRetriever); ok {
return rs.RetrieveShared(c.ctx, c.run.Owner, q, func(docOwner, projectID core.ID) bool {
return c.mgr.sharedKnowledgeAllow(string(c.run.Owner), projectID)
})
}
}
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 {
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()}
}
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)}
}
desc, _, _ := c.mgr.registry.Get(call.Name)
// N1 WP3:判定走 Policy 对象,会话放行经 SessionAllows 端口(键控口径不变:会话 × 工具名)。
pol := agentpolicy.Policy{
Mode: agentpolicy.Normalize(c.permissionMode),
SessionAllows: convAllowStore{mgr: c.mgr, conv: c.run.ConversationID},
}
effect := pol.Decide(call.Name, string(call.Arguments),
agentpolicy.Traits{ReadOnly: desc.ReadOnly, Edits: desc.Edits, Risk: desc.Risk})
if effect == agentpolicy.EffectDeny {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: fmt.Sprintf("计划模式拒绝了工具 %s", call.Name)}
}
if effect != agentpolicy.EffectAllow && (effect == agentpolicy.EffectAsk || policy.NeedsConfirm(call.Name)) {
resp, err := c.AskHITL(toolConfirmCard(call.Name, string(call.Arguments)))
if err != nil {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "hitl aborted: " + err.Error()}
}
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()}
}
if resp.ChoiceID == "session" {
c.mgr.rememberSessionAllow(c.run.ConversationID, call.Name)
} else if resp.ChoiceID != "yes" {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "用户取消了本次工具调用"}
}
}
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()}
}
// EXT-14:命令即工具——命中命令投影时经命令总分发(Entrance=agent、
// Actor/Owner=会话 owner、带会话上下文),同审计信封与高危确认卡收口。
if c.mgr.commands != nil {
for _, ct := range c.mgr.commands.CommandToolsFor(string(c.run.Owner)) {
if ct.ID != call.Name {
continue
}
var params map[string]any
if len(call.Arguments) > 0 {
_ = json.Unmarshal(call.Arguments, &params)
}
resp := c.mgr.commands.DispatchCommand(c.ctx, CommandRequest{
ID: call.Name, Params: params, Entrance: "agent",
Actor: c.run.Owner, Owner: c.run.Owner, ConversationID: c.run.ConversationID,
})
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()}
}
out, _ := json.Marshal(resp.Payload)
if resp.Card && resp.State == "accepted" {
return core.ToolCallResult{CallID: call.CallID, Status: "ok",
Output: "已创建确认卡,等待用户确认后执行:" + resp.Reason}
}
if resp.State != "committed" && resp.State != "accepted" {
return core.ToolCallResult{CallID: call.CallID, Status: "error",
Output: "命令 " + call.Name + " " + resp.State + ": " + resp.Reason}
}
return core.ToolCallResult{CallID: call.CallID, Status: "ok", Output: string(out)}
}
}
tc := &toolCtx{rc: c, callID: call.CallID}
res := c.mgr.registry.Execute(tc, call)
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()}
}
// 工具产物回填(交付物卡片根因修复):注册表工具执行期经 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" && utf8.RuneCountInString(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 = cutRunes(res.Output, limit) + "\n…(超长,完整结果已存为工件 " + art.Name + ")"
} else {
res.Output = cutRunes(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) CheckExternalEffect() error {
return t.rc.mgr.authorizeRunWork(t.rc.ctx, t.rc.run.Owner, t.rc.run.ProjectID)
}
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) }
func (t *toolCtx) AskUser(card core.HITLCard) (core.HITLResponse, error) {
return t.rc.AskHITL(card)
}
func (t *toolCtx) ExitPlanMode() error { return t.rc.ExitPlanMode() }
// 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.CompiledWorkflowRunner{}
}
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.authorizeRunWork(context.Background(), conv.Owner, conv.ProjectID); err != nil {
return core.Run{}, err
}
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.authorizeRunWork(context.Background(), conv.Owner, conv.ProjectID); err != nil {
return core.Run{}, err
}
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, actor ...string) error {
if err := m.claimConversation(conversationID); err != nil {
return err
}
// 执行所有权(U5/KTD4):PG 档在内存槽位之上叠加 DB 租约——跨实例的
// 同会话争抢由库裁决;接管递增 epoch 供结算围栏。失败时回退内存槽位。
if m.st.Dialect() == "postgres" {
var epoch int64
var err error
if m.accountEffectiveAuthorization != nil {
if len(actor) == 0 || actor[0] == "" {
m.releaseConversation(conversationID)
return store.ErrAccountForbidden
}
epoch, err = m.st.TryClaimExecutionAccountChecked(actor[0], conversationID, m.InstanceID(), "", 15*60, m.accountAllowImplicitLocal)
} else {
epoch, err = m.st.TryClaimExecution(conversationID, m.InstanceID(), "", 15*60)
}
if err != nil {
m.releaseConversation(conversationID)
if errors.Is(err, store.ErrExecutionBusy) {
return ErrConversationBusy
}
return err
}
m.execMu.Lock()
m.execEpoch[conversationID] = epoch
m.execMu.Unlock()
}
return nil
}
// InstanceID 本进程执行身份(组合根可经 SetInstanceTag 覆盖;多实例必须互异)。
func (m *Manager) InstanceID() string {
if m.instanceTag != "" {
return m.instanceTag
}
return "runmgr:" + m.addr
}
// SetInstanceTag 注入稳定实例身份与监听地址(组合根)。
func (m *Manager) SetInstanceTag(tag, addr string) {
m.instanceTag = tag
m.addr = addr
}
// 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) {
if err := m.authorizeRunWork(context.Background(), conv.Owner, conv.ProjectID); err != nil {
m.releaseConversation(conv.ID)
return core.Run{}, err
}
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,
}
var createErr error
if m.accountEffectiveAuthorization != nil {
createErr = m.st.CreateRunAccountChecked(&run, m.accountAllowImplicitLocal)
} else {
createErr = m.st.CreateRun(&run)
}
if err := createErr; err != nil {
m.releaseConversation(conv.ID)
return run, err
}
ctx, cancel := context.WithCancel(context.Background())
h := &runHandle{owner: run.Owner, 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)
var checkInputEffect func(context.Context) error
if m.accountEffectiveAuthorization != nil {
checkInputEffect = func(checkCtx context.Context) error {
return m.authorizeAccountEffective(checkCtx, conv.Owner)
}
}
inputPaths, inputErr := m.stageInputsChecked(ctx, conv, eff, inputs, checkInputEffect)
if inputErr == nil {
inputErr = m.persistInputTextsChecked(ctx, conv.Owner, conv.ID, attachments, checkInputEffect)
}
if inputErr != nil {
cancel()
m.mu.Lock()
delete(m.active, run.ID)
m.mu.Unlock()
m.releaseConversation(conv.ID)
m.setRunStatus(&run, core.RunFailed, core.PhaseDone)
return run, inputErr
}
inputPaths = ListWorkspaceInputs(m.workspaceRoot, conv.Owner, conv.ID, inputPaths)
rc := &runCtx{mgr: m, run: run, prof: prof, eff: eff, ctx: ctx, h: h, userMessage: userMessage, attachments: attachments, inputPaths: inputPaths, launch: launch, files: filestate.New(), permissionMode: conversationMode(m, run.Owner, run.ConversationID)}
// 材料整理(附件即保证):无文字/大材料发送瞬间预启动,说明注入第 0 回合;
// 其余带附件回合由 runtime stop 钩子兜底(模型已自行委派时尊重模型判断)。
if launch == nil {
rc.materialNote = m.materialIntake(rc)
}
go func() {
// 执行许可(U3,R8):排队有界;获取失败(队列满/取消/存储故障)以
// failed 收口——不执行任何模型调用,副作用无从产生。
if err := m.adm.acquire(rc.ctx, m.st, run.ID, run.Owner, m.accountEffectiveAuthorization != nil, m.accountAllowImplicitLocal); err != nil {
log.Printf("[runmgr] admission denied run %s: %v", run.ID, err)
m.setRunStatus(&run, core.RunFailed, core.PhaseThinking)
if uErr, ok := err.(error); ok {
m.st.UpdateRun(&core.Run{ID: run.ID, Status: core.RunFailed, Error: &core.ErrorPart{Code: "run.admission_denied", Message: uErr.Error()}})
}
m.emit(run.ConversationID, run.ID, core.EvRunFailed, core.RunFailedPayload{Code: "run.admission_denied", Message: "执行预算排队已满或已取消"})
m.releaseConversation(run.ConversationID)
return
}
defer m.adm.release(m.st, run.ID)
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()
if m.st != nil && m.st.Dialect() == "postgres" {
m.execMu.Lock()
epoch, ok := m.execEpoch[conversationID]
if ok {
delete(m.execEpoch, conversationID)
}
m.execMu.Unlock()
if ok {
m.st.ReleaseExecution(conversationID, m.InstanceID(), epoch)
}
}
}
// 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)
}()
if err := m.authorizeRunWork(rc.ctx, rc.run.Owner, rc.run.ProjectID); err != nil {
m.finishRun(rc, core.RunFailed, core.Usage{}, &core.ErrorPart{Code: "run.account_inactive", Message: err.Error()})
return
}
if err := m.setRunStatus(&rc.run, core.RunRunning, core.PhaseThinking); err != nil {
m.finishRun(rc, core.RunFailed, core.Usage{}, &core.ErrorPart{Code: "run.account_inactive", Message: err.Error()})
return
}
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) {
accountDenied := m.authorizeAccountEffective(rc.ctx, rc.run.Owner)
if accountDenied != nil {
status = core.RunFailed
errPart = &core.ErrorPart{Code: "run.account_inactive", Message: accountDenied.Error()}
if rc.h.cancel != nil {
rc.h.cancel()
}
}
m.mu.Lock()
usage.PromptTokens = rc.h.promptTokens
usage.CompletionTokens = rc.h.completionTokens
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
usage.LastPromptTokens = rc.h.lastPromptTokens
usage.LastCompletionTokens = rc.h.lastCompletionTokens
usage.CacheReadTokens = rc.h.cacheReadTokens
usage.CacheWriteTokens = rc.h.cacheWriteTokens
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 accountDenied != nil {
parts = nil
}
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}
if group, index, ok := m.st.PeekRegenPending(rc.run.ConversationID); ok {
msg.VariantGroup = group
msg.VariantIndex = index
}
}
updated, fenced, err := m.settleRunWithFence(rc, msg)
if err != nil {
log.Printf("[runmgr] settle run %s: %v", rc.run.ID, err)
m.failSettle(rc, err)
return
}
if fenced {
// 旧执行代次(U5/KTD4):租约已被他实例接管——本地不写终态;
// run 归属已移交,发围栏事件后静默退出(迟到副作用由此可见)。
log.Printf("[runmgr] settle run %s fenced: execution taken over (conv %s)", rc.run.ID, rc.run.ConversationID)
m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunFailed, core.RunFailedPayload{Code: "run.fenced", Message: "执行权已被其他实例接管"})
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.updateCardForEffectiveAccount(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.setCardStatusForEffectiveAccount(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) error {
r.Status = status
r.Phase = phase
if m.accountEffectiveAuthorization != nil && status != core.RunFailed && status != core.RunCancelled && status != core.RunInterrupted {
return m.st.UpdateRunAccountChecked(string(r.Owner), r, m.accountAllowImplicitLocal)
}
return 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)
}
// settleRunWithFence(U5):PG 档以本实例登记的 epoch 做围栏结算。
func (m *Manager) settleRunWithFence(rc *runCtx, msg *core.Message) (updated, fenced bool, err error) {
if m.st.Dialect() != "postgres" {
if m.accountEffectiveAuthorization != nil {
updated, err = m.st.SettleRunAccountChecked(&rc.run, msg, m.accountAllowImplicitLocal)
} else {
updated, err = m.st.SettleRun(&rc.run, msg)
}
return updated, false, err
}
m.execMu.Lock()
epoch, ok := m.execEpoch[string(rc.run.ConversationID)]
m.execMu.Unlock()
if !ok {
// 未登记(旧路径起跑):按库内当前代次校验(同实例放行)。
if es, gerr := m.st.GetExecution(string(rc.run.ConversationID)); gerr == nil {
epoch = es.Epoch
}
}
if m.accountEffectiveAuthorization != nil {
return m.st.SettleRunFencedAccountChecked(&rc.run, msg, m.InstanceID(), epoch, m.accountAllowImplicitLocal)
}
return m.st.SettleRunFenced(&rc.run, msg, m.InstanceID(), epoch)
}
// RunActiveLocally run 是否在本实例活跃执行(U5:跨实例命令路由判定)。
func (m *Manager) RunActiveLocally(runID string) bool {
root := runID
if r, err := m.st.GetRunByID(runID); err == nil && r.ParentRunID != "" {
root = r.ParentRunID
}
m.mu.Lock()
_, ok := m.active[root]
if !ok {
_, ok = m.active[runID]
}
m.mu.Unlock()
return ok
}
// 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
}
if m.projectWorkAuthorization != nil && (run.Owner != owner || run.ConversationID != card.ConversationID) {
return "", false, store.ErrNotFound
}
if err := m.authorizeRunWork(context.Background(), run.Owner, run.ProjectID); err != nil {
return "", false, err
}
// U7 双进程验收实测缺陷修复:run 不在本进程执行时不得先落 resolve——
// 卡一旦标记 resolved,另一实例 server 层的命令 fallback(resolveHITL 的
// !card.Resolved 判定)即被跳过,执行实例的 runner 永远等不到答案,
// run 只能等 HITL 超时。此处提前返回未决错误,让命令总线转交执行实例。
m.mu.Lock()
_, local := m.active[run.ID]
m.mu.Unlock()
if !local {
return "", false, fmt.Errorf("runmgr: run %s not executing locally (cross-instance resolve goes through command bus)", run.ID)
}
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)
// 转轨判定(两源):/ 命令,或意图闸门命中其他已注册意图。
// 命中与否经唯一 Router(internal/route)判定,与 chat 路由同一裁决(I1 WP3)。
pivot := false
pivotReason := ""
if strings.HasPrefix(trimmed, "/") {
pivot, pivotReason = true, "command"
} else if m.gate != nil {
if rec, ok := m.gate.(interface {
Recognize(ctx context.Context, input string) intentgate.MatchResult
}); ok {
res := rec.Recognize(context.Background(), trimmed)
dec := route.Decide(res, route.Env{})
if dec.StartFound {
pivot, pivotReason = true, "intent:"+res.ID
}
} else if branch, topID := m.gate.Match(context.Background(), trimmed); branch == "matched" {
pivot, pivotReason = true, "intent:"+topID
}
}
if pivot {
if err := m.authorizeRunWork(context.Background(), run.Owner, run.ProjectID); err != nil {
return "", false, err
}
_ = m.CancelRun(run.ID)
m.resolveHITLCardForEffectiveAccount(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})
_ = m.resolveProjectedCardForEffectiveAccount(owner, "hitl:"+cardID, "", answer)
_ = pivotReason
return trimmed, true, nil
}
}
if err := m.authorizeRunWork(context.Background(), run.Owner, run.ProjectID); err != nil {
return "", false, err
}
resolution := core.HITLResolvedPayload{CardID: core.ID(cardID), Answer: answer, Kind: kind}
if err := m.resolveHITLCardForEffectiveAccount(owner, cardID, resolution); err != nil {
return "", false, err
}
m.emit(run.ConversationID, run.ID, core.EvHITLResolved, resolution)
// 投影卡同步销项(双现;幂等)。
_ = m.resolveProjectedCardForEffectiveAccount(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
if m.accountEffectiveAuthorization != nil {
if err := m.st.UpdateRunAccountChecked(string(run.Owner), &run, m.accountAllowImplicitLocal); err != nil {
return "", false, err
}
} else if err := m.st.UpdateRun(&run); err != nil {
return "", false, err
}
}
return "", false, nil
}
// ---- 子代理(任务树)----
func (m *Manager) spawnSubagent(parentCtx context.Context, parent core.Run, agentID, name, task, hint string) (core.AgentResultPayload, error) {
if err := m.authorizeRunWork(parentCtx, parent.Owner, parent.ProjectID); err != nil {
return core.AgentResultPayload{}, err
}
rc, runner, err := m.startChild(parentCtx, parent, agentID, name, task, hint, false)
if err != nil {
return core.AgentResultPayload{}, err
}
// 父子互等规避(U3,R8):同步子代理阻塞父 run——父先让出执行许可,子
// 获取自己的许可(与父同池计入全局预算),子完成后父重取(可能排队;
// 取消语境下重取失败仅记录,run 由取消路径收口)。
m.adm.release(m.st, string(parent.ID))
defer func() {
if err := m.adm.acquire(parentCtx, m.st, string(parent.ID), parent.Owner, m.accountEffectiveAuthorization != nil, m.accountAllowImplicitLocal); err != nil {
log.Printf("[runmgr] admission reacquire after child %s: %v", parent.ID, err)
}
}()
if err := m.adm.acquire(parentCtx, m.st, rc.run.ID, rc.run.Owner, m.accountEffectiveAuthorization != nil, m.accountAllowImplicitLocal); err != nil {
m.setRunStatus(&rc.run, core.RunFailed, rc.run.Phase)
m.st.UpdateRun(&core.Run{ID: rc.run.ID, Status: core.RunFailed, Error: &core.ErrorPart{Code: "run.admission_denied", Message: err.Error()}})
m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunFailed, core.RunFailedPayload{Code: "run.admission_denied", Message: "子代理执行许可排队已满或已取消"})
return core.AgentResultPayload{}, err
}
defer m.adm.release(m.st, rc.run.ID)
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) {
return m.startChildOpts(parentCtx, parent, bgSpawnOpts{agentID: agentID, name: name, task: task, hint: hint}, detach)
}
// startChildOpts 在 startChild 之上支持系统派发选项(模型档等)。
func (m *Manager) startChildOpts(parentCtx context.Context, parent core.Run, o bgSpawnOpts, detach bool) (*runCtx, runtime.Runner, error) {
if err := m.authorizeRunWork(parentCtx, parent.Owner, parent.ProjectID); err != nil {
return nil, nil, err
}
agentID, name, task, hint := o.agentID, o.name, o.task, o.hint
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,
}
var createErr error
if m.accountEffectiveAuthorization != nil {
createErr = m.st.CreateRunAccountChecked(&child, m.accountAllowImplicitLocal)
} else {
createErr = m.st.CreateRun(&child)
}
if err := createErr; err != nil {
return nil, nil, err
}
// 独立转录(N2 WP5):派生请求落盘,续跑的重建凭据。
var checkChildEffect func(context.Context) error
if m.accountEffectiveAuthorization != nil {
checkChildEffect = func(checkCtx context.Context) error {
return m.authorizeAccountEffective(checkCtx, child.Owner)
}
}
if err := m.writeSubagentRequestChecked(parentCtx, child, agentID, name, task, hint, detach, checkChildEffect); err != nil {
m.setRunStatus(&child, core.RunFailed, core.PhaseDone)
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{owner: child.Owner, 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, subModel: o.model, files: filestate.New(), permissionMode: conversationMode(m, child.Owner, child.ConversationID)}
if err := m.setRunStatus(&rc.run, core.RunRunning, core.PhaseActing); err != nil {
cancel()
m.mu.Lock()
delete(m.active, child.ID)
m.mu.Unlock()
return nil, nil, err
}
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 := m.authorizeRunWork(rc.ctx, rc.run.Owner, rc.run.ProjectID)
if execErr == nil {
execErr = func() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("subagent panic: %v", r)
}
}()
return runner.Execute(rc)
}()
}
accountDenied := m.authorizeAccountEffective(rc.ctx, rc.run.Owner)
if accountDenied != nil {
execErr = accountDenied
if rc.h.cancel != nil {
rc.h.cancel()
}
}
// 子代理记账(N3 WP6):token 用量随子 run 入账(此前只有 DurationMs)。
usage := core.Usage{DurationMs: float64(time.Since(rc.h.started).Milliseconds())}
m.mu.Lock()
usage.PromptTokens = rc.h.promptTokens
usage.CompletionTokens = rc.h.completionTokens
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
usage.LastPromptTokens = rc.h.lastPromptTokens
usage.LastCompletionTokens = rc.h.lastCompletionTokens
usage.CacheReadTokens = rc.h.cacheReadTokens
usage.CacheWriteTokens = rc.h.cacheWriteTokens
m.mu.Unlock()
status := core.RunCompleted
summary := "完成"
payload := core.AgentResultPayload{AgentRun: rc.run.ID, Status: status, Summary: summary}
if execErr != nil {
if accountDenied != nil {
status = core.RunFailed
payload.Status = status
payload.Summary = accountDenied.Error()
} else 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
if m.accountEffectiveAuthorization != nil && status == core.RunCompleted {
if err := m.st.UpdateRunAccountChecked(string(child.Owner), &child, m.accountAllowImplicitLocal); err != nil {
accountDenied = err
status = core.RunFailed
child.Status = status
payload.Status = status
payload.Summary = err.Error()
_ = m.st.UpdateRun(&child)
}
} else {
_ = m.st.UpdateRun(&child)
}
if emitResult && accountDenied == nil {
m.emit(child.ConversationID, child.ID, core.EvAgentResult, payload)
}
m.mu.Lock()
subParts := append([]core.Part{}, rc.h.assistant...)
m.mu.Unlock()
if accountDenied != nil {
subParts = nil
}
// 独立转录落定(含终态 done 标记,供续跑幂等判定)。
transcriptCheck := func(checkCtx context.Context) error {
return m.authorizeAccountEffective(checkCtx, child.Owner)
}
if m.accountEffectiveAuthorization == nil {
transcriptCheck = nil
}
if err := m.writeSubagentTranscriptChecked(rc.ctx, child, subParts, string(status), transcriptCheck); err != nil {
log.Printf("[runmgr] subagent transcript %s: %v", child.ID, err)
}
fullText := partsText(subParts)
if len(subParts) > 0 {
_ = m.appendMessageForEffectiveAccount(child.Owner, &core.Message{ConversationID: child.ConversationID, RunID: child.ID, Role: core.RoleAssistant, Parts: subParts})
}
if fullText != "" {
payload.Text = capSubagentResult(rc, 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
}
// bgSpawnOpts 后台派发选项:模型 delegate_task 走零值;系统确定性派发
// (材料整理)携带模型档、卡片 KV、内容键、材料标记与排队许可。
type bgSpawnOpts struct {
agentID, name, task, hint string
model string // "chat" = 子代理用对话同档模型(材料整理)
kv [][2]string
taskKey string // 内容键(非空 = 派发前去重、完成后保留键于卡片)
material bool // 材料整理任务(通知回合衔接交付物选择)
queue bool // 满容量时可排队(系统任务;模型任务仍硬拒绝)
}
func (m *Manager) spawnBackground(parent core.Run, agentID, name, task, hint string) (core.ID, error) {
return m.spawnBackgroundOpts(parent, bgSpawnOpts{agentID: agentID, name: name, task: task, hint: hint})
}
func (m *Manager) spawnBackgroundOpts(parent core.Run, o bgSpawnOpts) (core.ID, error) {
if err := m.authorizeRunWork(context.Background(), parent.Owner, parent.ProjectID); err != nil {
return "", err
}
if parent.Kind == core.RunSubagent {
return "", fmt.Errorf("子代理不能再派发子代理")
}
if o.taskKey != "" {
convID := string(parent.ConversationID)
m.materialMu.Lock()
active := m.materialActive != nil && m.materialActive[convID][o.taskKey]
m.materialMu.Unlock()
if active {
return "", fmt.Errorf("同键后台任务已在运行(材料键 %s),见收件箱进度卡", truncate(o.taskKey, 12))
}
}
conv := parent.ConversationID
m.noticeMu.Lock()
if m.bgInFlight == nil {
m.bgInFlight = map[string]int{}
}
if m.bgInFlight[conv] >= maxBackgroundChildren {
if !o.queue {
m.noticeMu.Unlock()
return "", fmt.Errorf("本会话已有 %d 个后台子代理在运行", maxBackgroundChildren)
}
if m.bgQueue == nil {
m.bgQueue = map[string][]bgQueueItem{}
}
m.bgQueue[conv] = append(m.bgQueue[conv], bgQueueItem{parent: parent, opts: o})
m.noticeMu.Unlock()
// 排队卡先行(用户立即可见排队态);SourceRunID 与执行态在槽位
// 释放启动时回填。入队与建卡之间释放槽位的窗口由 pump 兜住:
// 下一次 decBackground 会尝试启动它。
card := m.createBackgroundCard(parent, "", o.name, o.task, o.kv, fmt.Sprintf("排队中(本会话已有 %d 个后台任务,释放后自动开始)", maxBackgroundChildren))
m.noticeMu.Lock()
if q := m.bgQueue[conv]; len(q) > 0 {
q[len(q)-1].card = card
}
m.noticeMu.Unlock()
return "", nil
}
m.bgInFlight[conv]++
m.noticeMu.Unlock()
return m.startBackgroundSlot(parent, o, core.ActivityCard{})
}
// startBackgroundSlot 持槽启动后台任务(调用方已占 bgInFlight)。
// preCard 非空时复用排队卡(回填 SourceRunID 并转执行态),否则新建执行卡。
func (m *Manager) startBackgroundSlot(parent core.Run, o bgSpawnOpts, preCard core.ActivityCard) (core.ID, error) {
if err := m.authorizeRunWork(context.Background(), parent.Owner, parent.ProjectID); err != nil {
m.decBackground(parent.ConversationID)
return "", err
}
rc, runner, err := m.startChildOpts(context.Background(), parent, o, true)
if err != nil {
m.decBackground(parent.ConversationID)
return "", err
}
if o.taskKey != "" {
m.materialKeyRegister(string(parent.ConversationID), o.taskKey)
}
// 慢通道可观测(任务快慢双速,SR-B10):后台子代理进收件箱进度卡,
// 完成后把结果回投该卡;通知回合路径保持不变,卡片是用户侧回收入口。
bgCard := preCard
if bgCard.ID == "" {
bgCard = m.createBackgroundCard(parent, rc.run.ID, o.name, o.task, o.kv, "")
} else {
m.promoteQueuedCard(parent.Owner, preCard, rc.run.ID)
}
kv := o.kv
go func() {
defer m.decBackground(parent.ConversationID)
if o.taskKey != "" {
defer m.materialKeyRelease(string(parent.ConversationID), o.taskKey)
}
// 后台子代理同样计入执行预算(U3:子代理预算计入全局)。
if err := m.adm.acquire(rc.ctx, m.st, rc.run.ID, parent.Owner, m.accountEffectiveAuthorization != nil, m.accountAllowImplicitLocal); err != nil {
log.Printf("[runmgr] admission denied background %s: %v", rc.run.ID, err)
m.setRunStatus(&rc.run, core.RunFailed, rc.run.Phase)
m.st.UpdateRun(&core.Run{ID: rc.run.ID, Status: core.RunFailed, Error: &core.ErrorPart{Code: "run.admission_denied", Message: err.Error()}})
m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunFailed, core.RunFailedPayload{Code: "run.admission_denied", Message: "后台子代理执行许可排队已满或已取消"})
m.finishBackgroundCard(parent.Owner, bgCard, core.AgentResultPayload{}, err, kv)
return
}
defer m.adm.release(m.st, rc.run.ID)
payload, err := m.finishChild(rc, runner, false)
m.finishBackgroundCard(parent.Owner, bgCard, payload, err, kv)
m.enqueueNotice(parent.ConversationID, bgNotice{owner: parent.Owner, name: o.name, payload: payload, material: o.material})
m.deliverNotices(parent.ConversationID)
}()
return rc.run.ID, nil
}
// pumpBackgroundQueue 槽位释放后按 FIFO 启动排队任务(decBackground 尾调)。
func (m *Manager) pumpBackgroundQueue(conv string) {
m.noticeMu.Lock()
if len(m.bgQueue[conv]) == 0 || m.bgInFlight[conv] >= maxBackgroundChildren {
m.noticeMu.Unlock()
return
}
item := m.bgQueue[conv][0]
m.bgQueue[conv] = m.bgQueue[conv][1:]
m.bgInFlight[conv]++
m.noticeMu.Unlock()
if _, err := m.startBackgroundSlot(item.parent, item.opts, item.card); err != nil {
log.Printf("[runmgr] background queue start: %v", err)
// 启动失败也要给排队卡一个终态,不能永久停在排队中。
m.finishBackgroundCard(item.parent.Owner, item.card, core.AgentResultPayload{}, err, item.opts.kv)
}
}
// createBackgroundCard 在收件箱登记慢通道进度卡。收件箱未启用(cards==nil)
// 或建卡失败时返回零值卡,完成回投随之跳过,不影响通知投递。
// kv 为系统派发任务的附加键值(材料键等),随完成回投保留;status 非空时
// 覆盖默认状态行(排队态文案)。
func (m *Manager) createBackgroundCard(parent core.Run, childID core.ID, name, task string, kv [][2]string, status string) core.ActivityCard {
if m.cards == nil {
return core.ActivityCard{}
}
title := strings.TrimSpace(name)
if title == "" {
title = "后台任务"
}
_ = task // 派发说明保留入参兼容;用户可见正文用 L1 骨架,不回填原始指令。
statusLabel := core.TaskSummaryStatusRunning
if status != "" {
statusLabel = status
}
focus := "正在执行:" + title
extra := append(append([][2]string(nil), kv...), [2]string{"通道", "慢通道 · 后台执行"})
body := (core.TaskSummaryView{Focus: focus}).RunningBody(statusLabel, extra)
card, err := m.createCardForEffectiveAccount(parent.Owner, core.ActivityCard{
Kind: core.CardKindProgress, Title: title, Status: core.CardActive, Tone: "info",
ConversationID: parent.ConversationID, ProjectID: parent.ProjectID, SourceRunID: childID,
Body: body,
}, "")
if err != nil {
log.Printf("[runmgr] background card create: %v", err)
return core.ActivityCard{}
}
return card
}
// promoteQueuedCard 排队任务获得槽位启动:回填 SourceRunID 并把状态行转为执行中。
func (m *Manager) promoteQueuedCard(owner string, card core.ActivityCard, childID core.ID) {
if m.cards == nil || card.ID == "" {
return
}
card.SourceRunID = childID
for i, node := range card.Body {
if node.Type != core.UIKV {
continue
}
for j, pair := range node.KV {
if pair[0] == core.TaskSummaryKVStatus || pair[0] == "状态" {
card.Body[i].KV[j][1] = core.TaskSummaryStatusRunning
}
}
}
if err := m.updateCardForEffectiveAccount(owner, card); err != nil {
log.Printf("[runmgr] queued card promote: %v", err)
}
}
// finishBackgroundCard 把后台子代理终态写回进度卡:成功=结果摘要(含工件行),
// 失败=错误说明并置 CardFailed(U1:失败不得显示完成灯)。kv 原样保留。
func (m *Manager) finishBackgroundCard(owner string, card core.ActivityCard, payload core.AgentResultPayload, err error, kv [][2]string) {
if m.cards == nil || card.ID == "" {
return
}
summary := strings.TrimSpace(payload.Summary)
tone := "ok"
status := core.CardDone
statusLabel := core.TaskSummaryStatusDone
if err != nil {
tone = "danger"
status = core.CardFailed
statusLabel = core.TaskSummaryStatusFailed
if summary == "" {
summary = err.Error()
}
summary = "执行失败:" + summary
}
if summary == "" {
summary = "(无结果摘要)"
}
extra := append(append([][2]string(nil), kv...), [2]string{"通道", "慢通道 · 后台执行"})
card.Body = (core.TaskSummaryView{OutcomeLine: summary}).FinishedBody(statusLabel, extra)
if uerr := m.updateCardForEffectiveAccount(owner, card); uerr != nil {
log.Printf("[runmgr] background card update: %v", uerr)
}
if _, serr := m.setCardStatusForEffectiveAccount(owner, card.ID, status, tone, ""); serr != nil {
log.Printf("[runmgr] background card status: %v", serr)
}
}
func (m *Manager) decBackground(convID string) {
m.noticeMu.Lock()
if m.bgInFlight == nil {
m.noticeMu.Unlock()
return
}
if m.bgInFlight[convID] > 0 {
m.bgInFlight[convID]--
}
m.noticeMu.Unlock()
// 槽位释放:按 FIFO 启动排队的系统任务(I3)。
m.pumpBackgroundQueue(convID)
}
func (m *Manager) enqueueNotice(convID string, n bgNotice) {
// 先落库(重启重投凭据),再进内存热队列;账号拒绝时不投递迟到结果。
if n.id == "" {
n.id = store.NewID("ntc")
}
notice := store.Notice{
ID: n.id, ConversationID: convID, Owner: n.owner, Name: n.name, Payload: n.payload,
}
var persistErr error
if m.accountEffectiveAuthorization != nil {
persistErr = m.st.AppendNoticeAccountChecked(notice, m.accountAllowImplicitLocal)
} else {
persistErr = m.st.AppendNotice(notice)
}
if persistErr != nil {
log.Printf("[runmgr] notice persist %s: %v", convID, persistErr)
return
}
m.noticeMu.Lock()
defer m.noticeMu.Unlock()
if m.notices == nil {
m.notices = map[string][]bgNotice{}
}
m.notices[convID] = append(m.notices[convID], n)
}
// LoadPendingNotices 启动时把未投递的通知装回内存队列并尝试投递
// (N2 WP5:重启重投)。投递遵循既有空闲语义(忙则等下一轮触发)。
func (m *Manager) LoadPendingNotices() {
items, err := m.st.ListNotices()
if err != nil {
log.Printf("[runmgr] notice load: %v", err)
return
}
byConv := map[string]bool{}
m.noticeMu.Lock()
if m.notices == nil {
m.notices = map[string][]bgNotice{}
}
for _, it := range items {
m.notices[it.ConversationID] = append(m.notices[it.ConversationID], bgNotice{
id: it.ID, owner: it.Owner, name: it.Name, payload: it.Payload,
})
byConv[it.ConversationID] = true
}
m.noticeMu.Unlock()
for convID := range byConv {
m.deliverNotices(convID)
}
}
// 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)
m.noticeMu.Unlock()
}()
// 迭代而非递归(U3 真库门禁实测):原实现的 defer 在投递失败重新入队后
// 同步再入 deliverNotices——持续失败(如会话忙/查库错误)时无限自递归,
// test-pg 慢速时序下栈溢出(job 2352)。失败本轮即返回:队列保留,
// 由下一次 enqueueNotice 或重启重投驱动;投递期间新到的通知由循环
// 尾部再取,不再经 defer 递归。
for {
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()
return
}
// 投递成功才删持久化行(失败保留下次重投)。
ids := make([]string, 0, len(items))
for _, n := range items {
if n.id != "" {
ids = append(ids, n.id)
}
}
if err := m.st.DeleteNotices(ids); err != nil {
log.Printf("[runmgr] notice delete: %v", err)
}
m.noticeMu.Lock()
items = append([]bgNotice{}, m.notices[convID]...)
if len(items) == 0 {
m.noticeMu.Unlock()
return
}
delete(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
}
if err := m.authorizeRunWork(context.Background(), owner, conv.ProjectID); 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))
}
// 材料整理完成 → 交付物选择职责(I2):汇报要点后立即发起待确认选择卡。
// runtime 侧对不发 confirm_choice 的弱模型另有 stop 兜底(一次性)。
materialSeen := false
for _, n := range items {
if n.material {
materialSeen = true
break
}
}
if materialSeen {
b.WriteString("\n这批材料的系统整理已完成。处理方式:\n1) 先用不超过 5 行汇报整理结果要点(来自上面的摘要)。\n2) 然后立即调用 confirm_choice 工具发起交付物选择卡:问题如「基于整理结果,接下来生成什么?」;选项取整理结果中的「建议后续处理」(3-4 项,description 写一句材料依据,最贴合的一项设 recommended),允许自由输入。\n3) 用户答复后按工具结果指引,用 delegate_task 的 background=true 启动对应后台任务。\n不要重复派发材料整理任务。\n")
if m.genuiAuto != nil {
if ok, _ := m.genuiAuto.Try(owner, convID, "material_done"); ok {
b.WriteString("\n")
b.WriteString(genUIAutoReminderText())
b.WriteString("\n")
}
}
}
b.WriteString("</system-reminder>")
msg := core.Message{
ConversationID: convID, Role: core.RoleSystem,
Parts: []core.Part{{Type: core.PartText, Text: b.String()}},
}
if err := m.authorizeRunWork(context.Background(), owner, conv.ProjectID); err != nil {
m.releaseConversation(convID)
return err
}
if err := m.appendMessageForEffectiveAccount(owner, &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 {
// 子代理 run 先把已收集的部分转录落定(中断可续跑的现场)。
if run, err := m.st.GetRunByID(id); err == nil && run.Kind == core.RunSubagent {
m.mu.Lock()
var parts []core.Part
if h, ok := m.active[id]; ok {
parts = append(parts, h.assistant...)
}
m.mu.Unlock()
var check func(context.Context) error
if m.accountEffectiveAuthorization != nil {
check = func(ctx context.Context) error { return m.authorizeAccountEffective(ctx, run.Owner) }
}
if err := m.writeSubagentTranscriptChecked(context.Background(), run, parts, "interrupted", check); err != nil {
log.Printf("[runmgr] interrupted transcript %s: %v", run.ID, err)
}
}
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.resolveHITLCardForEffectiveAccount(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 {
text := p.Text
// MiniMax 等模型把推理写成行内 <think> 块:子代理摘要/回投卡正文
// 只取 think 之外的正文(实测 <think> 前缀泄漏进收件箱卡片与通知)。
if body := stripInlineThink(text); strings.TrimSpace(body) != "" || !strings.Contains(text, "</think>") {
text = body
}
sb.WriteString(text)
sb.WriteString("\n")
}
}
return strings.TrimSpace(sb.String())
}
// stripInlineThink 去掉文本中的 <think>…</think> 段(完整闭合才剥,半截保守保留)。
func stripInlineThink(text string) string {
for {
start := strings.Index(text, "<think>")
if start < 0 {
return text
}
end := strings.Index(text[start:], "</think>")
if end < 0 {
return text
}
text = text[:start] + text[start+end+len("</think>"):]
}
}
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 {
cut := cutRunes(s, n)
if cut == s {
return s
}
return cut + "…"
}
func cutRunes(s string, n int) string {
if n < 0 {
n = 0
}
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n])
}
func capSubagentResult(rc *runCtx, text string) string {
shown, full := subagent.CapParentText(text)
if full == nil {
return shown
}
if art, err := rc.WriteArtifact("subagent-result.txt", "data", "text/plain", full); err == nil {
return shown + "\n完整结果见工件 " + art.Name
}
return shown + "\n完整结果超出 32000 字,已截断"
}
func (t *toolCtx) NoteFileRead(path string) error {
if t.rc == nil || t.rc.files == nil {
return nil
}
return t.rc.files.NoteRead(path)
}
func (t *toolCtx) CheckFileWrite(path string) error {
if t.rc == nil || t.rc.files == nil {
return nil
}
return t.rc.files.CheckWrite(path)
}