// Package runmgr 是 Run 生命周期枢纽(4.2/4.3):一切运行皆 Run、一切增量皆事件。 // 职责:run 状态机、事件持久化+广播(seq 游标)、HITL 挂起/恢复(三分支路由)、 // 子代理派生(任务树)、工具执行策略(画像三清单 + 确认卡片 + 截断转工件)、取消级联。 package runmgr import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "log" "path/filepath" "slices" "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" "agent-system-backend/pkg/extension" 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 // execEnvDecide 处理不挂在活动 run 上的 Python 环境审批卡(Detail 前缀 exec-env:)。 execEnvDecide func(actor, requestID string, allow, isolate bool) (string, error) 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 projectContextResolver func(context.Context, extension.ProjectContextRequest) (extension.ProjectContextBundle, error) projectEvidenceReader func(context.Context, extension.ProjectEvidenceRequest) (extension.ProjectEvidence, error) projectSourceRechecker func(context.Context, extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) projectWorkObservationEnabled bool projectWorkPreflight projectWorkPreflight projectRiskRespond projectRiskRespond // documentMigrationComparison is an internal host capability. It is never // exposed through tool arguments, settings, prompts, or child environments. documentMigrationComparison 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 // hitlAnsweredHook 仅测试接缝:HITL 答案交给 runner 之后同步一次,供用例 // 确定性构造"runner 抢先结算"的交错。生产装配不注入(nil = 直通)。 hitlAnsweredHook func(runID core.ID) } // 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 { binding taskBinding id string // 持久化队列主键(重启重投凭据) owner string name string payload core.AgentResultPayload material bool // 材料整理任务(持久化绑定字段) } // 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 toolCalls 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 } // SetExecEnvDecider 接上分层环境的审批。卡片不要求 run 仍在本进程执行。 func (m *Manager) SetExecEnvDecider(fn func(actor, requestID string, allow, isolate bool) (string, error)) { m.execEnvDecide = fn } // SetCards 注入收件箱卡服务(nil = 不产卡;T0 退回基线仍成立)。 func (m *Manager) SetCards(s *cardsvc.Service) { m.cards = s if s != nil { s.TaskCommitter = func(w store.TaskProjectionWrite) ([]core.StreamEvent, error) { if m.accountEffectiveAuthorization != nil && w.AccountActor == "" { w.AccountActor, w.AllowImplicitDefault = w.Owner, m.accountAllowImplicitLocal } return m.events.commitTask(w) } } } // 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 } // SetProjectWorkObservationEnabled is set at startup after the trusted // observer/preflight path is assembled. The disabled default preserves older // hosts and cannot be mistaken for successful source treatment. func (m *Manager) SetProjectWorkObservationEnabled(enabled bool) { m.projectWorkObservationEnabled = enabled } // SetProjectWorkGate installs the domain-owned preflight and risk-answer // callbacks. Both are required; a partial gate stays disabled. func (m *Manager) SetProjectWorkGate(preflight projectWorkPreflight, respond projectRiskRespond) { if preflight == nil || respond == nil { m.projectWorkPreflight, m.projectRiskRespond = nil, nil m.projectWorkObservationEnabled = false return } m.projectWorkPreflight, m.projectRiskRespond = preflight, respond m.projectWorkObservationEnabled = true } func (c *runCtx) requireProjectWorkDecision(action string) error { if !c.projectWorkObservationApplies() { return nil } for { decision, err := resolveProjectWorkDecision(c.ctx, string(c.run.Owner), string(c.run.ProjectID), string(c.run.ID), action, c.mgr.projectWorkPreflight, c.mgr.projectRiskRespond, c.AskHITL) if errors.Is(err, errProjectWorkPending) { select { case <-time.After(250 * time.Millisecond): continue case <-c.ctx.Done(): return c.ctx.Err() } } if err != nil { return err } if decision.State != extension.ProjectWorkProceed { return errors.New("project work action did not receive a committed proceed decision") } return c.mgr.setRunStatus(&c.run, core.RunRunning, c.run.Phase) } } // 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() } } // scratchHostFlag 按存储里的 host 标记判定本次 run 的基座项目是否为临时会话。 // ok=false 表示这次没判定成功(读不到项目),此时不缓存答案——run 生命周期内项目 // 不变,下次读得到时再判定。两个方向的 fail closed 由调用点各取所需。 func (c *runCtx) scratchHostFlag() (host bool, ok bool) { if c == nil || c.mgr == nil || c.mgr.st == nil || c.run.ProjectID == "" { return false, false } c.scratchMu.Lock() host, known := c.scratchHost, c.scratchKnown c.scratchMu.Unlock() if known { return host, true } host, err := c.mgr.st.IsScratchProjectID(string(c.run.ProjectID)) if err != nil { return false, false } c.scratchMu.Lock() c.scratchHost, c.scratchKnown = host, true c.scratchMu.Unlock() return host, true } // scratchProject 报告本次 run 的基座项目是否是临时会话,供隔离门禁使用(记忆、 // 上下文条目、检索、跨会话读取、记忆端口)。读不到项目时 fail closed:本次调用按 // 临时会话处理且不缓存这个未知答案——这些门禁只收紧,读不到宿主就不能为整个 run // 把它们全部放开。 func (c *runCtx) scratchProject() bool { if c == nil || c.mgr == nil || c.mgr.st == nil || c.run.ProjectID == "" { return false } host, ok := c.scratchHostFlag() if !ok { return true } return host } // confirmedScratchProject 只在存储里读到 host 标记后确认本次 run 是临时会话。 // 读不到项目时为假且不缓存——“临时会话”在这个方向上是放宽信号(RDM 观察/预检 // 门禁、项目上下文),一次读失败不能为整个 run 把它们放开。 func (c *runCtx) confirmedScratchProject() bool { host, ok := c.scratchHostFlag() return ok && host } // projectWorkObservationApplies 报告本回合是否走 RDM 观察/预检通道。临时会话 // 没有 RDM(provider 从未见过它),观察与预检门都不适用:它就是一次普通聊天。 func (c *runCtx) projectWorkObservationApplies() bool { return c.mgr.projectWorkObservationEnabled && c.run.ProjectID != "" && !c.confirmedScratchProject() } // authorizeProjectWork is the run-side project work gate. A scratch project is // never known to the domain provider (RDM would always report false), so the // stored ownership decides: its owner works without the provider, everyone else // is NotFound. Ordinary projects keep the provider decision unchanged. func (m *Manager) authorizeProjectWork(ctx context.Context, owner string, id core.ID) error { if m.st != nil { if handled, err := m.st.ScratchProjectDecision(string(id), owner); err != nil { return err } else if handled { return nil } } 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 工具族)的沙箱根目录。 // 实际会话沙箱为 ///;空值=未配置(工具执行报错)。 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 projectContextReq extension.ProjectContextRequest projectContextMu sync.Mutex projectEvidenceMu sync.Mutex projectEvidenceUsed []projectEvidenceRecord 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 // scratchHost 缓存本次 run 的基座项目是否为临时会话(U2 隔离门禁); // scratchKnown 为 false 表示还没判定成功过(读不到项目),不缓存未知答案。 scratchHost bool scratchKnown bool scratchMu sync.Mutex // 材料整理(附件即保证):预启动说明注入第 0 回合;ensureMaterial 兜底幂等。 materialNote string workContext string materialEnsured bool task taskBinding sources materialSourceRegistry } // 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.h.toolCalls += u.ToolCalls 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 } // 临时会话只带本会话自己的上下文条目:owner 级/项目级条目会把别的会话的 // 材料带进来,与“私有会话”相反。 scratch := c.scratchProject() out := make([]runtime.ContextInjection, 0, len(list)) for _, e := range list { if !e.Enabled { continue } if scratch && e.ConversationID != c.run.ConversationID { 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 } // 临时会话不读 user/project 级记忆:记忆是跨会话的,正好是被隔离的那一层。 // 会话内状态(SessionNote)不受影响。 if c.scratchProject() { 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 } if c.projectWorkObservationApplies() && (typ == core.EvMessageDelta || typ == core.EvThinkingDelta) { // Project reply text is released only through the committed message after // the complete run-source manifest receives a domain risk decision. return nil } if c.task.cardID != "" && (typ == core.EvToolCall || typ == core.EvToolResult) { label := "正在读取和核对资料" if typ == core.EvToolResult { label = "已取得工具结果,继续整理" } if e := c.mgr.updateTaskActivity(c.task, core.TaskPhase{ID: "tool", Label: label, ObservedAt: time.Now().UTC()}); e != nil && !errors.Is(e, errActivityThrottled) { log.Printf("[runmgr] task activity: %v", e) } } _, 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) { // 临时会话没有可检索的共享知识:@knowledge 与 knowledge_search 一律空手 // 而非报错(会话里的附件仍以材料形式进入本回合)。 if c.scratchProject() { return []core.RetrievalHit{}, nil } hits, err := c.retrieveRaw(q) if err != nil || !c.projectWorkObservationApplies() { return hits, err } for _, hit := range hits { if hit.Doc.ID == "" { return nil, errors.New("project retrieval hit lacks a stable source ID") } hash := sha256.Sum256([]byte(hit.Doc.Chunk)) _, err := c.mgr.st.AppendRunWorkObservation(store.RunWorkObservationInput{ RunID: string(c.run.ID), SourceKind: "retrieval-hit", SourceID: string(hit.Doc.ID), SourceVersion: hex.EncodeToString(hash[:]), Text: hit.Doc.Chunk, Delivery: "complete", Metadata: map[string]string{"query": q.Text, "ref": hit.Doc.Ref, "title": hit.Doc.Title, "source": hit.Doc.Source, "hitKind": hit.Kind, "retrievedAt": time.Now().UTC().Format(time.RFC3339Nano)}, }) if err != nil { return nil, err } } if err := c.requireProjectWorkDecision("model.consume.retrieval"); err != nil { return nil, err } return hits, nil } func (c *runCtx) retrieveRaw(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 { hits, err := rs.RetrieveAuthorized(c.ctx, c.run.Owner, q, allow) return c.materialRetrievalResult(q, hits, err) } 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 c.materialRetrievalResult(q, out, nil) } if c.mgr.retriever != nil { if c.mgr.sharedKnowledgeAllow != nil { if rs, ok := c.mgr.retriever.(sharedRetriever); ok { hits, err := 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.materialRetrievalResult(q, hits, err) } } hits, err := c.mgr.retriever.Retrieve(c.ctx, c.run.Owner, q) return c.materialRetrievalResult(q, hits, err) } hits, err := c.mgr.st.SearchKnowledge(c.run.Owner, q) return c.materialRetrievalResult(q, hits, err) } // 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 } if c.projectWorkObservationApplies() { return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "project command tool awaits trusted source preflight"} } var params map[string]any if len(call.Arguments) > 0 { _ = json.Unmarshal(call.Arguments, ¶ms) } 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 } if c.projectWorkObservationApplies() { if call.CallID == "" { return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "project tool result lacks a stable call ID"} } hash := sha256.Sum256([]byte(res.Output)) meta := map[string]string{"toolName": call.Name, "status": res.Status, "sourceStatus": "transient", "observedAt": time.Now().UTC().Format(time.RFC3339Nano)} if res.Artifact != nil { meta["artifactId"] = string(res.Artifact.ID) } if _, err := c.mgr.st.AppendRunWorkObservation(store.RunWorkObservationInput{ RunID: string(c.run.ID), SourceKind: "tool-result", SourceID: call.CallID, SourceVersion: hex.EncodeToString(hash[:]), Text: res.Output, Delivery: "complete", Metadata: meta, }); err != nil { return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "project tool result could not be recorded"} } if err := c.requireProjectWorkDecision("model.consume.tool-result"); err != nil { return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()} } } // 超长截断并转工件引用(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 加载当前会话并校验项目闸门;出站检查由调用方按目标是否为自身决定。 // 临时会话直接关闭出站读取:它的材料不流向任何其他会话(U2)。 func (t *toolCtx) crossReadSelf() (core.Conversation, error) { if t.rc.scratchProject() { return core.Conversation{}, ErrCrossReadDisabled } 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 } // scratchPeer reports whether a conversation lives in a scratch project. Peers are // grouped by project so the trusted lookup runs once per distinct project. func (t *toolCtx) scratchPeer(c core.Conversation, cache map[core.ID]bool) bool { if seen, ok := cache[c.ProjectID]; ok { return seen } scratch, err := t.rc.mgr.st.IsScratchProjectID(string(c.ProjectID)) if err != nil { scratch = false } cache[c.ProjectID] = scratch return scratch } 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 } scratchProjects := map[core.ID]bool{} out := make([]core.Conversation, 0, len(convs)) for _, c := range convs { // 只读他人:排除自身;私有会话不出现(存在性本身是信息)。 if c.ID == self.ID || c.CrossPrivate { continue } // 临时会话永不作为同伴出现:它不是共享材料,而是别人的私有会话。 if t.scratchPeer(c, scratchProjects) { 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 && t.scratchPeer(target, map[core.ID]bool{}) { return nil, ErrCrossReadForbidden } 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, refs ...*WorkReference) (core.Run, error) { return m.startClaimedRun(conv, userMessage, attachments, inputs, nil, refs...) } // 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, refs ...*WorkReference) (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 } if m.projectWorkObservationEnabled { if userMessage.ID == "" { m.releaseConversation(conv.ID) return run, errors.New("project run requires a committed user message source") } var linkErr error if m.accountEffectiveAuthorization != nil { linkErr = m.st.SetMessageRunIDAccountChecked(string(run.Owner), string(userMessage.ID), string(run.ID), m.accountAllowImplicitLocal) } else { linkErr = m.st.SetMessageRunID(string(userMessage.ID), string(run.ID)) } if linkErr != nil { run.Status, run.Phase = core.RunFailed, core.PhaseDone run.Error = &core.ErrorPart{Code: "run.project_source_link_failed", Message: linkErr.Error()} _ = m.st.UpdateRun(&run) m.releaseConversation(conv.ID) return run, linkErr } userMessage.RunID = run.ID } 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)} if launch == nil && len(attachments) == 0 { var ref *WorkReference if len(refs) > 0 { ref = refs[0] } var bindErr error rc.workContext, bindErr = m.bindWorkContext(conv, userMessage, ref, run.ID) if bindErr != 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, bindErr } } if strings.HasPrefix(rc.workContext, workReceiptPrefix) { runner = workReceiptRunner{} } // 材料整理(附件即保证):无文字/大材料发送瞬间预启动,说明注入第 0 回合; // 其余带附件回合由 runtime stop 钩子兜底(模型已自行委派时尊重模型判断)。 if launch == nil { decision := m.materialIntakeDecision(rc) rc.materialNote = decision.Note if decision.needsAcknowledgement(messageText(userMessage)) { runner = materialAckRunner{decision: decision, attachmentCount: len(attachments), hasUserFocus: messageText(userMessage) != ""} } } 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}) if err := rc.requireProjectWorkDecision("conversation.run"); err != nil { m.finishRun(rc, core.RunFailed, core.Usage{}, &core.ErrorPart{Code: "run.project_work_blocked", Message: err.Error()}) return } 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 usage.ToolCalls = rc.h.toolCalls 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 } } if msg != nil && status == core.RunCompleted && m.projectWorkObservationEnabled && rc.run.ProjectID != "" { gateErr := rc.stageAssistantWorkSources(msg.Parts) if gateErr == nil { gateErr = rc.requireProjectWorkDecision("assistant.final") } if gateErr != nil { status = core.RunFailed errPart = &core.ErrorPart{Code: "run.project_reply_blocked", Message: gateErr.Error()} msg.Parts = []core.Part{{Type: core.PartError, Err: errPart}} } } // HITL/preflight may have moved the persisted run through waiting/running. // Restore the intended terminal state immediately before the fenced commit. rc.run.Status = status rc.run.Phase = core.PhaseDone rc.run.Error = errPart 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) } // resumeRunFromWaitingHITL 把等待人工输入的 run 推进回 running:条件更新, // 由 store 报告是否命中(见 store.ResumeRunFromWaitingHITL)。 func (m *Manager) resumeRunFromWaitingHITL(r *core.Run) (bool, error) { if m.accountEffectiveAuthorization != nil { return m.st.ResumeRunFromWaitingHITLAccountChecked(string(r.Owner), r, m.accountAllowImplicitLocal) } return m.st.ResumeRunFromWaitingHITL(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) } if strings.HasPrefix(card.Detail, "exec-env:") { return m.resolveExecEnvCard(owner, card, resp) } 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) } // 恢复运行状态必须先于把答案交给 runner(U7 双实例验收实测缺陷修复): // 反序时 runner 立即被唤醒并抢先结算,写下 completed/ended_at,随后这里的 // 迟到写把行覆盖回 running/ended_at=NULL——run 永远卡在 running 且无执行者, // 会话永不释放,之后每次提交都是 409。前置 + 条件更新后,交出答案之后本函数 // 不再写 run 行,runner 是该行此后唯一的写者。 if run.Status == core.RunWaitingHITL { applied, err := m.resumeRunFromWaitingHITL(&run) if err != nil { return "", false, err } if !applied { // 行已被 runner 或取消路径推进:不改写,只记录,交由权威写者收口。 log.Printf("[runmgr] run %s already advanced past waiting_hitl on resolve; resume skipped", run.ID) } } select { case h.hitlCh <- hitlAnswer{resp: resp}: default: return "", false, fmt.Errorf("runmgr: hitl channel busy") } if m.hitlAnsweredHook != nil { m.hitlAnsweredHook(run.ID) } return "", false, nil } func (m *Manager) resolveExecEnvCard(owner string, card core.HITLCard, resp core.HITLResponse) (string, bool, error) { if m.execEnvDecide == nil { return "", false, fmt.Errorf("runmgr: exec env decider is not configured") } id := strings.TrimPrefix(card.Detail, "exec-env:") if i := strings.IndexByte(id, '\n'); i >= 0 { id = id[:i] } choice := resp.ChoiceID allow := choice == "approve" || choice == "isolate" detail, err := m.execEnvDecide(owner, id, allow, choice == "isolate") if err != nil { return "", false, err } if err := m.resolveHITLCardForEffectiveAccount(owner, string(card.ID), core.HITLResolvedPayload{ CardID: card.ID, Kind: "choice", Answer: choice, }); err != nil { return "", false, err } return detail, 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, } if o.childID != "" { child.ID = o.childID } 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 usage.ToolCalls = rc.h.toolCalls 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 execErr != nil { child.Error = &core.ErrorPart{Code: "run.subagent_failed", Message: execErr.Error()} } 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) } 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 rc.task.cardID != "" { finalParts := make([]core.Part, 0, len(subParts)) for _, part := range subParts { if !part.Process { finalParts = append(finalParts, part) } } fullText = partsText(finalParts) if strings.TrimSpace(fullText) == "" && status == core.RunCompleted { payload.Summary = "整理已结束,没有可用的最终正文;请核验已有阶段发现与限制" } } if len(subParts) > 0 { if persistErr := m.appendMessageForEffectiveAccount(child.Owner, &core.Message{ConversationID: child.ConversationID, RunID: child.ID, Role: core.RoleAssistant, Parts: subParts}); persistErr != nil { log.Printf("[runmgr] subagent result persistence %s: %v", child.ID, persistErr) status = core.RunFailed child.Status = status payload.Status = status payload.Summary = resultPersistenceFailedSummary child.Error = &core.ErrorPart{Code: "run.result_persistence_failed", Message: payload.Summary} _ = m.st.UpdateRun(&child) _ = m.writeSubagentTranscriptChecked(rc.ctx, child, subParts, string(status), transcriptCheck) if guardErr := m.authorizeRunWork(context.Background(), child.Owner, child.ProjectID); guardErr != nil { accountDenied = guardErr subParts = nil fullText = "" payload.Summary = guardErr.Error() } } } if fullText != "" { payload.Text = capSubagentResult(rc, fullText) if !emitResult { payload.Text = finalWorkText(subParts) } 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) } if emitResult && accountDenied == nil { m.emit(child.ConversationID, child.ID, core.EvAgentResult, payload) } 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 } const resultPersistenceFailedSummary = "执行记录保存失败;已生成的文字结果保留在工作卡,完整过程暂不可回看。" func finalWorkText(parts []core.Part) string { var final []core.Part for _, part := range parts { if part.Type == core.PartText && !part.Process { final = append(final, part) } } return strings.TrimSpace(partsText(final)) } // bgSpawnOpts 后台派发选项:模型 delegate_task 走零值;系统确定性派发 // (材料整理)携带模型档、卡片 KV、内容键、材料标记与排队许可。 type bgSpawnOpts struct { childID core.ID // reserved by a durable optional-choice dispatch agentID, name, task, hint string model string // "chat" = 子代理用对话同档模型(材料整理) kv [][2]string taskKey string // 内容键(非空 = 派发前去重、完成后保留键于卡片) material bool // 材料整理任务(通知回合衔接交付物选择) queue bool // 满容量时可排队(系统任务;模型任务仍硬拒绝) documents []core.ParsedDocument sourceMessageID string binding taskBinding stopLease func() } 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 m.shuttingDown.Load() { return "", fmt.Errorf("runmgr: shutting down") } if err := m.authorizeRunWork(context.Background(), parent.Owner, parent.ProjectID); err != nil { return "", err } if parent.Kind == core.RunSubagent { return "", fmt.Errorf("子代理不能再派发子代理") } conv := parent.ConversationID m.noticeMu.Lock() if m.bgInFlight == nil { m.bgInFlight = map[string]int{} } if m.bgInFlight[conv] >= maxBackgroundChildren && !o.queue { m.noticeMu.Unlock() return "", fmt.Errorf("本会话已有 %d 个后台子代理在运行", maxBackgroundChildren) } if o.taskKey != "" { m.materialMu.Lock() active := m.materialActive[conv][o.taskKey] m.materialMu.Unlock() if active { m.noticeMu.Unlock() return "", fmt.Errorf("同键后台任务已登记") } } binding, card, err := m.registerBackgroundTask(parent, o) if err != nil { m.noticeMu.Unlock() return "", err } o.binding = binding o.stopLease = m.maintainTaskLease(binding) if o.taskKey != "" { m.materialKeyRegister(conv, o.taskKey) } if m.bgInFlight[conv] >= maxBackgroundChildren { if m.bgQueue == nil { m.bgQueue = map[string][]bgQueueItem{} } m.bgQueue[conv] = append(m.bgQueue[conv], bgQueueItem{parent: parent, opts: o, card: card}) m.noticeMu.Unlock() return "", nil } m.bgInFlight[conv]++ m.noticeMu.Unlock() return m.startBackgroundSlot(parent, o, card) } // 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.finishTaskProjection(o.binding, core.AgentResultPayload{}, err) if o.stopLease != nil { o.stopLease() } m.materialKeyRelease(parent.ConversationID, o.taskKey) m.decBackground(parent.ConversationID) return "", err } bgCard := preCard rc.task = o.binding rc.attachments = o.documents rc.sources = materialSources(o.documents, int(rc.eff.GetNumber(materialPerDocKey, materialPerDocDef))) var cardErr error switch { case bgCard.Task != nil: cardErr = m.startTaskProjection(o.binding, rc.run.ID) bgCard.SourceRunID = rc.run.ID case bgCard.ID == "": bgCard, cardErr = m.createBackgroundCardChecked(parent, rc.run.ID, o.name, o.task, o.kv, "") default: cardErr = m.promoteQueuedCard(parent.Owner, preCard, rc.run.ID) bgCard.SourceRunID = rc.run.ID } if cardErr != nil { rc.h.cancel() now := time.Now().UTC() rc.run.Status = core.RunFailed rc.run.Phase = core.PhaseDone rc.run.EndedAt = &now rc.run.Error = &core.ErrorPart{Code: "run.work_card_unavailable", Message: "工作卡登记失败,后台任务未执行。"} _ = m.st.UpdateRun(&rc.run) m.emit(parent.ConversationID, rc.run.ID, core.EvAgentResult, core.AgentResultPayload{AgentRun: rc.run.ID, Status: core.RunFailed, Summary: rc.run.Error.Message}) m.mu.Lock() delete(m.active, rc.run.ID) m.mu.Unlock() m.finishBackgroundCard(parent.Owner, bgCard, core.AgentResultPayload{}, fmt.Errorf("工作卡登记失败,后台任务未执行"), o.kv) if o.stopLease != nil { o.stopLease() } if o.taskKey != "" { m.materialKeyRelease(parent.ConversationID, o.taskKey) } m.decBackground(parent.ConversationID) return "", fmt.Errorf("background task card registration: %w", cardErr) } kv := o.kv go func() { defer m.decBackground(parent.ConversationID) if o.stopLease != nil { defer o.stopLease() } 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) // The original work card is the only delivery surface. A completed child // must never claim the foreground or manufacture a blocking question. m.emit(parent.ConversationID, rc.run.ID, core.EvAgentResult, m.safeTaskNotice(parent.Owner, bgCard, payload)) }() 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 { card, _ := m.createBackgroundCardChecked(parent, childID, name, task, kv, status) return card } func (m *Manager) createBackgroundCardChecked(parent core.Run, childID core.ID, name, task string, kv [][2]string, status string) (core.ActivityCard, error) { if m.cards == nil { return core.ActivityCard{}, nil } 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, Work: &core.WorkCard{}, }, "") if err != nil { log.Printf("[runmgr] background card create: %v", err) return card, err } return card, nil } // promoteQueuedCard 排队任务获得槽位启动:回填 SourceRunID 并把状态行转为执行中。 func (m *Manager) promoteQueuedCard(owner string, card core.ActivityCard, childID core.ID) error { if m.cards == nil || card.ID == "" { return nil } current, version, err := m.st.GetCardVersion(owner, card.ID) if err != nil { return err } if current.Status != core.CardActive { return store.ErrConflict } card = current 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.saveWorkCard(owner, card, version); err != nil { log.Printf("[runmgr] queued card promote: %v", err) return err } return nil } // 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 } if card.Task != nil { if e := m.finishTaskProjection(bindingFrom(card, owner), payload, err); e != nil { log.Printf("[runmgr] task finish: %v", e) } } 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{"通道", "慢通道 · 后台执行"}) completed := time.Now().UTC() result, _, resultErr := m.savedWorkResult(owner, card) if err != nil && payload.Status != core.RunCompleted && payload.Text != "" && m.authorizeRunWork(context.Background(), owner, card.ProjectID) == nil { result = payload.Text resultErr = nil } if resultErr != nil { if err == nil { log.Printf("[runmgr] saved background result unavailable: %v", resultErr) return // A later authorized card read reconciles from the saved child. } // Dispatch can fail before a child exists. Its known failure must still // leave the queued card with a visible terminal explanation. result = summary } hasTextResult := result != "" if result == "" { if err != nil { result = summary } else { result = "本任务未返回文字结果。请查看已保存的文件与完整执行记录。" } } work := &core.WorkCard{Result: result, ResultVersion: 1, CompletedAt: &completed} if err == nil && hasTextResult && (materialKVOf(card) != "" || slices.ContainsFunc(kv, func(p [2]string) bool { return p[0] == materialKVKey })) { // Suggestions replace the generic choices only when verified against the // committed task result; the choice stays optional and never blocks. if card.Task != nil && m.materialNoticeUsable(owner, card, payload) { work.Decision = materialWorkDecision(parseMaterialSuggestions(payload.Text)) } if work.Decision == nil { work.Decision = &core.WorkDecision{Mode: "single", State: "open", Options: []core.WorkOption{ {ID: "summary", Label: "生成摘要"}, {ID: "keypoints", Label: "提取重点"}, {ID: "review", Label: "梳理待确认事项"}, }} } } for attempt := 0; attempt < 3; attempt++ { current, version, readErr := m.st.GetCardVersion(owner, card.ID) if readErr != nil { return } if current.Work != nil && current.Work.ResultVersion > 0 && current.Work.Result != "" { return } // Task cards keep the body, tone and status owned by their projection. if current.Task == nil { current.Body = (core.TaskSummaryView{OutcomeLine: summary}).FinishedBody(statusLabel, extra) current.Tone = tone if current.Status != core.CardDismissed { current.Status = status } } current.Work = work if updateErr := m.saveWorkCard(owner, current, version); updateErr != nil { if errors.Is(updateErr, store.ErrConflict) { continue } log.Printf("[runmgr] background card update: %v", updateErr) } return } } 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 == "" { if n.binding.attemptID != "" { n.id = "ntc_" + n.binding.attemptID } else { n.id = store.NewID("ntc") } } notice := store.Notice{ ID: n.id, ConversationID: convID, Owner: n.owner, Name: n.name, Payload: n.payload, TaskID: n.binding.taskID, AttemptID: n.binding.attemptID, CardID: n.binding.cardID, Material: n.material, } 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() { m.recoverWorkDispatches() 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 { b := taskBinding{} if it.CardID != "" { card, e := m.st.GetCard(it.Owner, it.CardID) if e != nil || card.Task == nil || card.Task.TaskID != it.TaskID || card.Task.AttemptID != it.AttemptID { continue } if card.Task.DeliveryState == "reported" { _ = m.st.DeleteNotice(it.ID) continue } b = bindingFrom(card, it.Owner) } m.notices[it.ConversationID] = append(m.notices[it.ConversationID], bgNotice{ id: it.ID, owner: it.Owner, name: it.Name, payload: it.Payload, material: it.Material, binding: b, }) 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 } // Delivery is independent of execution completion; a readable result survives notification failure. for _, n := range items { if n.binding.cardID != "" { if e := m.mutateTask(n.binding, func(t *core.TaskSnapshot) error { if !t.Terminal() { return store.ErrConflict } t.DeliveryState = "reported" return nil }); e != nil { log.Printf("[runmgr] task notice state: %v", e) } } } // 投递成功才删持久化行(失败保留下次重投)。 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 } owner := items[0].owner conv, err := m.st.GetConversation(owner, convID) if err != nil { return err } if err := m.authorizeRunWork(context.Background(), owner, conv.ProjectID); err != nil { return err } for _, n := range items { if n.owner != owner { return store.ErrNotFound } if n.binding.cardID != "" { // A task-bound result already lives on its own task card; recheck the // binding at delivery and emit only the sanitized notice. if m.cards == nil { return fmt.Errorf("notice.scope: task cards unavailable") } card, e := m.st.GetCard(owner, n.binding.cardID) if e != nil || card.Task == nil || card.Task.TaskID != n.binding.taskID || card.Task.AttemptID != n.binding.attemptID || card.ConversationID != convID { return fmt.Errorf("notice.scope: invalid task binding") } m.emit(convID, n.payload.AgentRun, core.EvAgentResult, m.safeTaskNotice(owner, card, n.payload)) continue } cards, listErr := m.st.ListCards(owner, convID) if listErr != nil { return listErr } found := false for _, card := range cards { if card.SourceRunID == n.payload.AgentRun { found = true if card.Work == nil { m.finishBackgroundCard(owner, card, n.payload, nil, backgroundCardKV(card)) } break } } if !found && m.cards != nil { card := m.createBackgroundCard(core.Run{Owner: owner, ProjectID: conv.ProjectID, ConversationID: convID}, n.payload.AgentRun, n.name, "", nil, "") var resultErr error if n.payload.Status != core.RunCompleted { resultErr = fmt.Errorf("%s", n.payload.Summary) } m.finishBackgroundCard(owner, card, n.payload, resultErr, nil) } 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) } if m.cards != nil { if e := m.reconcileTaskProjections(); e != nil { log.Printf("[runmgr] task shutdown reconciliation: %v", e) } } m.noticeMu.Lock() queues := m.bgQueue m.bgQueue = map[string][]bgQueueItem{} m.noticeMu.Unlock() for _, queue := range queues { for _, item := range queue { if item.opts.stopLease != nil { item.opts.stopLease() } m.materialKeyRelease(item.parent.ConversationID, item.opts.taskKey) } } } // 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) } if m.cards != nil { return m.reconcileTaskProjections() } 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 之外的正文(实测 前缀泄漏进收件箱卡片与通知)。 if body := stripInlineThink(text); strings.TrimSpace(body) != "" || !strings.Contains(text, "") { text = body } sb.WriteString(text) sb.WriteString("\n") } } return strings.TrimSpace(sb.String()) } // stripInlineThink 去掉文本中的 … 段(完整闭合才剥,半截保守保留)。 func stripInlineThink(text string) string { for { start := strings.Index(text, "") if start < 0 { return text } end := strings.Index(text[start:], "") if end < 0 { return text } text = text[:start] + text[start+end+len(""):] } } 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) }