Details (original message): Implement the backend: store/bus/runmgr core, mock and LLM runners (agent-native client), HITL three-branch routing, subagent runs, builtin and declarative HTTP tools, ingest parsers (md/txt/docx/pdf-text), knowledge fulltext, layered settings, profiles, plugin host, REST+SSE API, single-binary entrypoint; e2e and unit tests.
533 lines
18 KiB
Go
533 lines
18 KiB
Go
// Package runmgr 是 Run 生命周期枢纽(4.2/4.3):一切运行皆 Run、一切增量皆事件。
|
||
// 职责:run 状态机、事件持久化+广播(seq 游标)、HITL 挂起/恢复(三分支路由)、
|
||
// 子代理派生(任务树)、工具执行策略(画像三清单 + 确认卡片 + 截断转工件)、取消级联。
|
||
package runmgr
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
core "agent-system-core"
|
||
"agent-system-backend/internal/bus"
|
||
"agent-system-backend/internal/profiles"
|
||
"agent-system-backend/internal/runtime"
|
||
"agent-system-backend/internal/settings"
|
||
"agent-system-backend/internal/store"
|
||
"agent-system-backend/internal/tools"
|
||
)
|
||
|
||
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 // 子代理脚本回放
|
||
|
||
mu sync.Mutex
|
||
active map[string]*runHandle
|
||
}
|
||
|
||
type runHandle struct {
|
||
cancel context.CancelFunc
|
||
hitlCh chan hitlAnswer
|
||
assistant []core.Part
|
||
started time.Time
|
||
}
|
||
|
||
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{}}
|
||
}
|
||
|
||
// ---- runCtx 实现(runtime.RunCtx)----
|
||
|
||
type runCtx struct {
|
||
mgr *Manager
|
||
run core.Run
|
||
prof core.AgentProfile
|
||
eff core.EffectiveSettings
|
||
ctx context.Context
|
||
h *runHandle
|
||
userMessage core.Message
|
||
attachments []core.ParsedDocument
|
||
subTask string
|
||
subHint string
|
||
}
|
||
|
||
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, _ := c.mgr.st.LastMessages(c.run.ConversationID, 200)
|
||
return hist
|
||
}
|
||
func (c *runCtx) Attachments() []core.ParsedDocument { return c.attachments }
|
||
func (c *runCtx) Subtask() string { return c.subTask }
|
||
func (c *runCtx) Tools() []core.ToolDesc { return c.mgr.registry.List() }
|
||
|
||
func (c *runCtx) Emit(typ core.StreamEventType, payload any) error {
|
||
_, err := c.mgr.emit(c.run.ConversationID, c.run.ID, typ, payload)
|
||
return err
|
||
}
|
||
|
||
func (c *runCtx) SetAssistantParts(parts []core.Part) error {
|
||
c.mgr.mu.Lock()
|
||
c.h.assistant = parts
|
||
c.mgr.mu.Unlock()
|
||
return nil
|
||
}
|
||
|
||
func (c *runCtx) AskHITL(card core.HITLCard) (core.HITLResponse, error) {
|
||
card.ID = store.NewID("hitl")
|
||
card.RunID = c.run.ID
|
||
card.ConversationID = c.run.ConversationID
|
||
if card.CreatedAt == "" {
|
||
card.CreatedAt = time.Now().UTC().Format(time.RFC3339)
|
||
}
|
||
if err := c.mgr.st.SaveHITLCard(&card); err != nil {
|
||
return core.HITLResponse{}, err
|
||
}
|
||
c.mgr.setRunStatus(&c.run, core.RunWaitingHITL, c.run.Phase)
|
||
if _, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvHITLRequest, card); err != nil {
|
||
return core.HITLResponse{}, err
|
||
}
|
||
select {
|
||
case ans := <-c.h.hitlCh:
|
||
return ans.resp, ans.err
|
||
case <-c.ctx.Done():
|
||
return core.HITLResponse{}, c.ctx.Err()
|
||
}
|
||
}
|
||
|
||
func (c *runCtx) SpawnSubagent(name, task, hint string) (core.AgentResultPayload, error) {
|
||
return c.mgr.spawnSubagent(c.ctx, c.run, name, task, hint)
|
||
}
|
||
|
||
func (c *runCtx) UpdatePlan(p core.PlanSnapshot) error {
|
||
p.PlanID = c.run.ConversationID
|
||
if err := c.mgr.st.SavePlan(c.run.ConversationID, p); err != nil {
|
||
return err
|
||
}
|
||
_, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvPlanUpdate, core.PlanUpdatePayload{Plan: p})
|
||
return err
|
||
}
|
||
|
||
func (c *runCtx) UpdateTodos(l core.TodoList) error {
|
||
l.ConversationID = c.run.ConversationID
|
||
if err := c.mgr.st.SaveTodos(c.run.ConversationID, l); err != nil {
|
||
return err
|
||
}
|
||
_, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvTodoUpdate, core.TodoUpdatePayload{List: l})
|
||
return err
|
||
}
|
||
|
||
func (c *runCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) {
|
||
art := core.Artifact{ProjectID: c.run.ProjectID, Name: name, Kind: kind, MIME: mime, SourceRun: c.run.ID}
|
||
if err := c.mgr.st.CreateArtifact(&art, content); err != nil {
|
||
return art, err
|
||
}
|
||
if _, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvArtifactCreated, core.ArtifactPayload{Artifact: art}); err != nil {
|
||
return art, err
|
||
}
|
||
// 工件文本进知识索引(全文检索最小实现)。
|
||
if kind == "document" && len(content) < 200000 {
|
||
_ = c.mgr.st.AddKnowledge(core.KnowledgeDoc{ProjectID: c.run.ProjectID, Source: "artifact", Title: name, Ref: art.ID, Chunk: string(content)})
|
||
}
|
||
return art, nil
|
||
}
|
||
|
||
func (c *runCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) {
|
||
if q.TopK <= 0 {
|
||
q.TopK = int(c.eff.GetNumber("retrieval.topK", 5))
|
||
}
|
||
if q.ProjectID == "" {
|
||
q.ProjectID = c.run.ProjectID
|
||
}
|
||
return c.mgr.st.SearchKnowledge(q)
|
||
}
|
||
|
||
// ExecuteTool 执行画像策略:deny→错误;confirm→HITL confirm 卡片;执行→截断转工件。
|
||
func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
|
||
policy := c.prof.Tools
|
||
if !policy.ToolAllowed(call.Name) {
|
||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: fmt.Sprintf("tool %q denied by profile policy", call.Name)}
|
||
}
|
||
if policy.NeedsConfirm(call.Name) {
|
||
var argsPreview string
|
||
if len(call.Arguments) > 0 {
|
||
argsPreview = truncate(string(call.Arguments), 600)
|
||
}
|
||
card := core.HITLCard{
|
||
Question: fmt.Sprintf("执行工具 %s?", call.Name), Kind: core.HITLKindConfirm,
|
||
Choices: []core.HITLChoice{
|
||
{ID: "yes", Label: "执行", Description: "按上述参数调用", Impact: "该工具将访问外部资源并返回结果", Recommended: true},
|
||
{ID: "no", Label: "取消", Description: "跳过本次调用", Impact: "模型会收到调用被拒绝的说明"},
|
||
},
|
||
AllowFreeInput: false,
|
||
}
|
||
_ = argsPreview
|
||
resp, err := c.AskHITL(card)
|
||
if err != nil {
|
||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "hitl aborted: " + err.Error()}
|
||
}
|
||
if resp.ChoiceID != "yes" {
|
||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "用户取消了本次工具调用"}
|
||
}
|
||
}
|
||
res := c.mgr.registry.Execute(toolCtx{rc: c}, call)
|
||
// 超长截断并转工件引用(4.10)。
|
||
limit := c.prof.Context.MaxToolResultChars
|
||
if limit <= 0 {
|
||
limit = int(c.eff.GetNumber("context.maxToolResultChars", 20000))
|
||
}
|
||
if res.Status == "ok" && len(res.Output) > limit {
|
||
if art, err := c.WriteArtifact("tool-"+call.Name+"-"+call.CallID+".txt", "data", "text/plain", []byte(res.Output)); err == nil {
|
||
res.Artifact = &art
|
||
res.Output = res.Output[:limit] + "\n…(超长,完整结果已存为工件 " + art.Name + ")"
|
||
} else {
|
||
res.Output = res.Output[:limit] + "…(超长截断,工件写入失败)"
|
||
}
|
||
res.Truncated = true
|
||
}
|
||
return res
|
||
}
|
||
|
||
type toolCtx struct{ rc *runCtx }
|
||
|
||
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) {
|
||
return t.rc.WriteArtifact(name, kind, mime, content)
|
||
}
|
||
|
||
// ---- run 生命周期 ----
|
||
|
||
// StartConversationRun 为一条已落库的用户消息启动 conversation run。
|
||
// attachments 为已解析的附件文档(server 层经 ingest 处理)。
|
||
func (m *Manager) StartConversationRun(conv core.Conversation, userMessage core.Message, attachments []core.ParsedDocument) (core.Run, error) {
|
||
prof, _ := m.profiles.Get(conv.ProfileID)
|
||
eff := m.settings.Effective(conv.ProjectID)
|
||
run := core.Run{
|
||
ID: store.NewID("run"), ConversationID: conv.ID, ProjectID: conv.ProjectID,
|
||
Kind: core.RunConversation, ProfileID: prof.ID, Status: core.RunPending, Phase: core.PhaseThinking,
|
||
}
|
||
if err := m.st.CreateRun(&run); err != nil {
|
||
return run, err
|
||
}
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
h := &runHandle{cancel: cancel, hitlCh: make(chan hitlAnswer, 1), started: time.Now()}
|
||
m.mu.Lock()
|
||
m.active[run.ID] = h
|
||
m.mu.Unlock()
|
||
|
||
runner := m.runner
|
||
if eff.GetBool("runtime.mock", false) {
|
||
runner = m.mock
|
||
}
|
||
rc := &runCtx{mgr: m, run: run, prof: prof, eff: eff, ctx: ctx, h: h, userMessage: userMessage, attachments: attachments}
|
||
go m.execute(rc, runner)
|
||
return run, nil
|
||
}
|
||
|
||
// execute 运行至终态并收敛:状态、usage、助手消息、收尾事件。
|
||
func (m *Manager) execute(rc *runCtx, runner runtime.Runner) {
|
||
defer func() {
|
||
m.mu.Lock()
|
||
delete(m.active, rc.run.ID)
|
||
m.mu.Unlock()
|
||
}()
|
||
m.setRunStatus(&rc.run, core.RunRunning, core.PhaseThinking)
|
||
m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunPhase, core.RunPhasePayload{Phase: core.PhaseThinking})
|
||
|
||
execErr := func() (err error) {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
err = fmt.Errorf("runner panic: %v", r)
|
||
}
|
||
}()
|
||
return runner.Execute(rc)
|
||
}()
|
||
|
||
usage := core.Usage{DurationMs: float64(time.Since(rc.h.started).Milliseconds())}
|
||
if execErr != nil {
|
||
if rc.ctx.Err() != nil {
|
||
m.finishRun(rc, core.RunCancelled, usage, &core.ErrorPart{Code: "run.cancelled", Message: "已取消"})
|
||
return
|
||
}
|
||
m.finishRun(rc, core.RunFailed, usage, &core.ErrorPart{Code: "run.failed", Message: execErr.Error()})
|
||
return
|
||
}
|
||
m.finishRun(rc, core.RunCompleted, usage, nil)
|
||
}
|
||
|
||
func (m *Manager) finishRun(rc *runCtx, status core.RunStatus, usage core.Usage, errPart *core.ErrorPart) {
|
||
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
|
||
m.st.UpdateRun(&rc.run)
|
||
|
||
// 终态权威消息树:落库助手消息(含过程部件标记)。
|
||
m.mu.Lock()
|
||
parts := append([]core.Part{}, rc.h.assistant...)
|
||
m.mu.Unlock()
|
||
if len(parts) > 0 {
|
||
msg := core.Message{ConversationID: rc.run.ConversationID, RunID: rc.run.ID, Role: core.RoleAssistant, Parts: parts}
|
||
if err := m.st.AppendMessage(&msg); err == nil {
|
||
// 消息树权威已落库;hitl 摘要部件的卡片状态由前端对账收敛。
|
||
}
|
||
if errPart != nil {
|
||
m.st.AppendMessage(&core.Message{ConversationID: rc.run.ConversationID, RunID: rc.run.ID, Role: core.RoleAssistant, Parts: []core.Part{{Type: core.PartError, Err: errPart}}})
|
||
}
|
||
} else if errPart != nil {
|
||
m.st.AppendMessage(&core.Message{ConversationID: rc.run.ConversationID, RunID: rc.run.ID, Role: core.RoleAssistant, Parts: []core.Part{{Type: core.PartError, Err: errPart}}})
|
||
}
|
||
|
||
switch status {
|
||
case core.RunCompleted:
|
||
usagePayload := core.RunCompletedPayload{Usage: usage}
|
||
// 把工具调用数补进 usage(由 runner 在部件里体现,这里取事件计数)。
|
||
m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunCompleted, usagePayload)
|
||
case core.RunFailed:
|
||
m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunFailed, core.RunFailedPayload{Code: errPart.Code, Message: errPart.Message})
|
||
}
|
||
}
|
||
|
||
// setRunStatus 更新并广播 phase(状态不单独出事件,phase 事件携带)。
|
||
func (m *Manager) setRunStatus(r *core.Run, status core.RunStatus, phase core.RunPhase) {
|
||
r.Status = status
|
||
r.Phase = phase
|
||
m.st.UpdateRun(r)
|
||
}
|
||
|
||
// emit 持久化事件并广播;返回 seq。
|
||
func (m *Manager) emit(conversationID, runID string, typ core.StreamEventType, payload any) (int64, error) {
|
||
e := core.StreamEvent{
|
||
ConversationID: conversationID, RunID: runID,
|
||
TS: time.Now().UTC().Format(time.RFC3339Nano), V: core.ProtocolVersion, Type: typ,
|
||
}
|
||
seq, err := m.st.AppendEvent(&e, payload)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
e.Seq = seq
|
||
m.bus.Publish(e)
|
||
return seq, nil
|
||
}
|
||
|
||
// CancelRun 取消 run 及其子 run(ctx 级联)。
|
||
func (m *Manager) CancelRun(runID string) error {
|
||
r, err := m.st.GetRun(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(cardID string, resp core.HITLResponse) (pivotText string, pivoted bool, err error) {
|
||
card, err := m.st.GetHITLCard(cardID)
|
||
if err != nil {
|
||
return "", false, err
|
||
}
|
||
if card.Resolved {
|
||
return "", false, fmt.Errorf("runmgr: card %s already resolved", cardID)
|
||
}
|
||
run, err := m.st.GetRun(card.RunID)
|
||
if err != nil {
|
||
return "", false, err
|
||
}
|
||
|
||
kind := "choice"
|
||
answer := ""
|
||
if resp.ChoiceID != "" {
|
||
for _, c := range card.Choices {
|
||
if c.ID == resp.ChoiceID {
|
||
answer = c.Label
|
||
break
|
||
}
|
||
}
|
||
} else if resp.FreeText != "" {
|
||
kind = "free"
|
||
answer = resp.FreeText
|
||
if strings.HasPrefix(strings.TrimSpace(resp.FreeText), "/") {
|
||
// 命令转轨:取消挂起 run,等价于用户直接发送该命令。
|
||
_ = m.CancelRun(run.ID)
|
||
m.st.ResolveHITLCard(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})
|
||
return strings.TrimSpace(resp.FreeText), true, nil
|
||
}
|
||
}
|
||
resolution := core.HITLResolvedPayload{CardID: core.ID(cardID), Answer: answer, Kind: kind}
|
||
if err := m.st.ResolveHITLCard(cardID, resolution); err != nil {
|
||
return "", false, err
|
||
}
|
||
m.emit(run.ConversationID, run.ID, core.EvHITLResolved, resolution)
|
||
|
||
m.mu.Lock()
|
||
h, ok := m.active[run.ID]
|
||
m.mu.Unlock()
|
||
if !ok {
|
||
return "", false, fmt.Errorf("runmgr: run %s not active (may have ended)", run.ID)
|
||
}
|
||
select {
|
||
case h.hitlCh <- hitlAnswer{resp: resp}:
|
||
default:
|
||
return "", false, fmt.Errorf("runmgr: hitl channel busy")
|
||
}
|
||
// 恢复运行状态。
|
||
if run.Status == core.RunWaitingHITL {
|
||
run.Status = core.RunRunning
|
||
m.st.UpdateRun(&run)
|
||
}
|
||
return "", false, nil
|
||
}
|
||
|
||
// ---- 子代理(任务树)----
|
||
|
||
func (m *Manager) spawnSubagent(parentCtx context.Context, parent core.Run, name, task, hint string) (core.AgentResultPayload, error) {
|
||
child := core.Run{
|
||
ID: store.NewID("run"), ParentRunID: parent.ID, ConversationID: parent.ConversationID,
|
||
ProjectID: parent.ProjectID, Kind: core.RunSubagent, ProfileID: parent.ProfileID,
|
||
Status: core.RunPending, Phase: core.PhaseThinking, AgentName: name,
|
||
}
|
||
if err := m.st.CreateRun(&child); err != nil {
|
||
return core.AgentResultPayload{}, err
|
||
}
|
||
m.emit(parent.ConversationID, parent.ID, core.EvAgentSpawn, core.AgentSpawnPayload{AgentRun: child.ID, Name: name, Task: task})
|
||
|
||
ctx, cancel := context.WithCancel(parentCtx)
|
||
h := &runHandle{cancel: cancel, hitlCh: make(chan hitlAnswer, 1), started: time.Now()}
|
||
m.mu.Lock()
|
||
m.active[child.ID] = h
|
||
m.mu.Unlock()
|
||
|
||
prof, _ := m.profiles.Get(parent.ProfileID)
|
||
eff := m.settings.Effective(parent.ProjectID)
|
||
rc := &runCtx{mgr: m, run: child, prof: prof, eff: eff, ctx: ctx, h: h, subTask: task, subHint: hint}
|
||
m.setRunStatus(&rc.run, core.RunRunning, core.PhaseActing)
|
||
|
||
runner := m.subRunner
|
||
if eff.GetBool("runtime.mock", false) || runner == nil {
|
||
runner = m.mockSub
|
||
}
|
||
execErr := func() (err error) {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
err = fmt.Errorf("subagent panic: %v", r)
|
||
}
|
||
}()
|
||
return runner.Execute(rc)
|
||
}()
|
||
|
||
usage := core.Usage{DurationMs: float64(time.Since(h.started).Milliseconds())}
|
||
status := core.RunCompleted
|
||
summary := "完成"
|
||
payload := core.AgentResultPayload{AgentRun: child.ID, Status: status, Summary: summary}
|
||
if execErr != nil {
|
||
if 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.EndedAt = &now
|
||
child.Status = status
|
||
child.Phase = core.PhaseDone
|
||
child.Usage = &usage
|
||
m.st.UpdateRun(&child)
|
||
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", name, payload.Summary)
|
||
}
|
||
return payload, nil
|
||
}
|
||
|
||
// InterruptRunning 服务停机前把在途 run 标记为 interrupted(4.2 恢复策略)。
|
||
func (m *Manager) InterruptRunning() {
|
||
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 {
|
||
r, err := m.st.GetRun(id)
|
||
if err != nil || r.Status == core.RunCompleted || r.Status == core.RunFailed || r.Status == core.RunCancelled {
|
||
continue
|
||
}
|
||
r.Status = core.RunInterrupted
|
||
m.st.UpdateRun(&r)
|
||
}
|
||
}
|
||
|
||
func truncate(s string, n int) string {
|
||
if len(s) <= n {
|
||
return s
|
||
}
|
||
return s[:n] + "…"
|
||
}
|