Add background queue for system tasks and flip ui.cardflow default on

Why: at the 3-per-conversation cap the guaranteed material task was dropped silently, and long deliverables stayed in chat because cardflow defaulted off (user ruling 2026-10-11).
Evidence: new queue lifecycle test (queued card, hard-reject for model path, FIFO start on release, card promote) passes; full go test ./... exit 0 (local, SELF).
Impact: queueable system spawns create a queued progress card at capacity and start FIFO when a slot frees (memory-only queue; restart limitation noted); model delegate_task still hard-rejects; ui.cardflow default true (EffectNewRun).
Refs: Dev task .task/feature-material-intake-flow.md I3.
This commit is contained in:
2026-10-11 16:56:59 +00:00
parent 42867aabf4
commit 16ed7b281b
4 changed files with 173 additions and 11 deletions
+1
View File
@@ -229,6 +229,7 @@ func (m *Manager) materialSpawn(rc *runCtx, key string) string {
kv: [][2]string{{materialKVKey, short}, {"文件", fmt.Sprintf("%d 份", len(rc.attachments))}},
taskKey: key,
material: true,
queue: true, // 容量满时排队显示而非放弃(附件即保证)
}
if _, err := m.spawnBackgroundOpts(rc.run, opts); err != nil {
log.Printf("[runmgr] material intake spawn: %v", err)
+75
View File
@@ -1,6 +1,7 @@
package runmgr
import (
"fmt"
"strings"
"testing"
@@ -207,3 +208,77 @@ func TestMaterialNoticeContract(t *testing.T) {
t.Fatal("plain background notice must not carry the choice instruction")
}
}
// 容量排队:满 3 时材料任务建排队卡不占位;槽位释放按 FIFO 启动并回填状态。
func TestMaterialQueuedWhenFull(t *testing.T) {
c, _ := newCardSpawnEnv(t, nil)
c.mgr.runner = noopRunner{}
c.mgr.subRunner = &holdRunner{release: make(chan struct{})} // 占位用
fills := make([]*holdRunner, 0, maxBackgroundChildren)
for i := 0; i < maxBackgroundChildren; i++ {
h := &holdRunner{release: make(chan struct{})}
fills = append(fills, h)
c.mgr.subRunner = h
if _, err := c.SpawnBackground("agent.sub.general", fmt.Sprintf("占位%d", i), "占位", ""); err != nil {
t.Fatal(err)
}
}
c.attachments = materialDocs()
if note := c.mgr.materialIntake(c); !strings.Contains(note, "系统已自动启动材料整理后台任务") {
t.Fatalf("queued intake note = %q", note)
}
// 排队卡:active + 状态=排队中,且没有第 4 个子 run。
waitUntil(t, func() bool {
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
if err != nil {
return false
}
for _, card := range cards {
if card.Title == materialCardTitle && strings.Contains(cardBodyText(card), "排队中") {
return true
}
}
return false
})
if ids, err := c.mgr.st.SubagentRunIDs(c.run.ConversationID); err == nil && len(ids) != maxBackgroundChildren {
t.Fatalf("subagent runs = %d, want %d (queued task must not start yet)", len(ids), maxBackgroundChildren)
}
// 模型路径仍硬拒绝。
if _, err := c.SpawnBackground("agent.sub.general", "第四个", "任务", ""); err == nil {
t.Fatal("model-path spawn should hard-reject at capacity")
}
// 释放一个槽位 → 排队任务启动(第 4 个子 run),卡片转执行态。
close(fills[0].release)
waitUntil(t, func() bool {
ids, err := c.mgr.st.SubagentRunIDs(c.run.ConversationID)
return err == nil && len(ids) == maxBackgroundChildren+1
})
waitUntil(t, func() bool {
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
if err != nil {
return false
}
for _, card := range cards {
if card.Title == materialCardTitle && card.SourceRunID != "" && strings.Contains(cardBodyText(card), "执行中") {
return true
}
}
return false
})
for _, h := range fills[1:] {
close(h.release)
}
}
func cardBodyText(card core.ActivityCard) string {
var b strings.Builder
for _, node := range card.Body {
if node.Type == core.UIMarkdown {
b.WriteString(node.Markdown)
}
for _, kv := range node.KV {
b.WriteString(kv[0] + kv[1])
}
}
return b.String()
}
+95 -9
View File
@@ -90,12 +90,23 @@ type Manager struct {
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
}
const maxBackgroundChildren = 3
// bgQueueItem 一个等待槽位的系统派发后台任务(建卡先行,子 run 启动延后)。
type bgQueueItem struct {
parent core.Run
opts bgSpawnOpts
card core.ActivityCard
}
// bgNotice 是一条尚未投递给父会话的后台子代理结果。
type bgNotice struct {
id string // 持久化队列主键(重启重投凭据)
@@ -1775,13 +1786,14 @@ func (m *Manager) finishChild(rc *runCtx, runner runtime.Runner, emitResult bool
}
// bgSpawnOpts 后台派发选项:模型 delegate_task 走零值;系统确定性派发
// (材料整理)携带模型档、卡片 KV、内容键与材料标记。
// (材料整理)携带模型档、卡片 KV、内容键、材料标记与排队许可。
type bgSpawnOpts struct {
agentID, name, task, hint string
model string // "chat" = 子代理用对话同档模型(材料整理)
kv [][2]string
taskKey string // 内容键(非空 = 派发前去重、完成后保留键于卡片)
material bool // 材料整理任务(通知回合衔接交付物选择)
queue bool // 满容量时可排队(系统任务;模型任务仍硬拒绝)
}
func (m *Manager) spawnBackground(parent core.Run, agentID, name, task, hint string) (core.ID, error) {
@@ -1801,17 +1813,40 @@ func (m *Manager) spawnBackgroundOpts(parent core.Run, o bgSpawnOpts) (core.ID,
return "", fmt.Errorf("同键后台任务已在运行(材料键 %s),见收件箱进度卡", truncate(o.taskKey, 12))
}
}
conv := parent.ConversationID
m.noticeMu.Lock()
if m.bgInFlight == nil {
m.bgInFlight = map[string]int{}
}
if m.bgInFlight[parent.ConversationID] >= maxBackgroundChildren {
if m.bgInFlight[conv] >= maxBackgroundChildren {
if !o.queue {
m.noticeMu.Unlock()
return "", fmt.Errorf("本会话已有 %d 个后台子代理在运行", maxBackgroundChildren)
}
if m.bgQueue == nil {
m.bgQueue = map[string][]bgQueueItem{}
}
m.bgQueue[conv] = append(m.bgQueue[conv], bgQueueItem{parent: parent, opts: o})
m.noticeMu.Unlock()
return "", fmt.Errorf("本会话已有 %d 个后台子代理在运行", maxBackgroundChildren)
// 排队卡先行(用户立即可见排队态);SourceRunID 与执行态在槽位
// 释放启动时回填。入队与建卡之间释放槽位的窗口由 pump 兜住:
// 下一次 decBackground 会尝试启动它。
card := m.createBackgroundCard(parent, "", o.name, o.task, o.kv, fmt.Sprintf("排队中(本会话已有 %d 个后台任务,释放后自动开始)", maxBackgroundChildren))
m.noticeMu.Lock()
if q := m.bgQueue[conv]; len(q) > 0 {
q[len(q)-1].card = card
}
m.noticeMu.Unlock()
return "", nil
}
m.bgInFlight[parent.ConversationID]++
m.bgInFlight[conv]++
m.noticeMu.Unlock()
return m.startBackgroundSlot(parent, o, core.ActivityCard{})
}
// startBackgroundSlot 持槽启动后台任务(调用方已占 bgInFlight)。
// preCard 非空时复用排队卡(回填 SourceRunID 并转执行态),否则新建执行卡。
func (m *Manager) startBackgroundSlot(parent core.Run, o bgSpawnOpts, preCard core.ActivityCard) (core.ID, error) {
rc, runner, err := m.startChildOpts(context.Background(), parent, o, true)
if err != nil {
m.decBackground(parent.ConversationID)
@@ -1822,7 +1857,12 @@ func (m *Manager) spawnBackgroundOpts(parent core.Run, o bgSpawnOpts) (core.ID,
}
// 慢通道可观测(任务快慢双速,SR-B10):后台子代理进收件箱进度卡,
// 完成后把结果回投该卡;通知回合路径保持不变,卡片是用户侧回收入口。
bgCard := m.createBackgroundCard(parent, rc.run.ID, o.name, o.task, o.kv)
bgCard := preCard
if bgCard.ID == "" {
bgCard = m.createBackgroundCard(parent, rc.run.ID, o.name, o.task, o.kv, "")
} else {
m.promoteQueuedCard(parent.Owner, preCard, rc.run.ID)
}
kv := o.kv
go func() {
defer m.decBackground(parent.ConversationID)
@@ -1847,10 +1887,29 @@ func (m *Manager) spawnBackgroundOpts(parent core.Run, o bgSpawnOpts) (core.ID,
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 为系统派发任务的附加键值(材料键等),随完成回投保留。
func (m *Manager) createBackgroundCard(parent core.Run, childID core.ID, name, task string, kv [][2]string) core.ActivityCard {
// kv 为系统派发任务的附加键值(材料键等),随完成回投保留;status 非空时
// 覆盖默认状态行(排队态文案)。
func (m *Manager) createBackgroundCard(parent core.Run, childID core.ID, name, task string, kv [][2]string, status string) core.ActivityCard {
if m.cards == nil {
return core.ActivityCard{}
}
@@ -1858,9 +1917,12 @@ func (m *Manager) createBackgroundCard(parent core.Run, childID core.ID, name, t
if title == "" {
title = "后台任务"
}
if status == "" {
status = "执行中,完成后自动回投结果"
}
body := []core.UINode{
{Type: core.UIMarkdown, Markdown: truncate(strings.TrimSpace(task), 400)},
{Type: core.UIKV, KV: append(append([][2]string(nil), kv...), [2]string{"通道", "慢通道 · 后台执行"}, [2]string{"状态", "执行中,完成后自动回投结果"})},
{Type: core.UIKV, KV: append(append([][2]string(nil), kv...), [2]string{"通道", "慢通道 · 后台执行"}, [2]string{"状态", status})},
}
card, err := m.cards.Create(parent.Owner, core.ActivityCard{
Kind: core.CardKindProgress, Title: title, Status: core.CardActive, Tone: "info",
@@ -1874,6 +1936,27 @@ func (m *Manager) createBackgroundCard(parent core.Run, childID core.ID, name, t
return card
}
// promoteQueuedCard 排队任务获得槽位启动:回填 SourceRunID 并把状态行转为执行中。
func (m *Manager) promoteQueuedCard(owner string, card core.ActivityCard, childID core.ID) {
if m.cards == nil || card.ID == "" {
return
}
card.SourceRunID = childID
for i, node := range card.Body {
if node.Type != core.UIKV {
continue
}
for j, kv := range node.KV {
if kv[0] == "状态" {
card.Body[i].KV[j][1] = "执行中,完成后自动回投结果"
}
}
}
if err := m.cards.Update(owner, card); err != nil {
log.Printf("[runmgr] queued card promote: %v", err)
}
}
// finishBackgroundCard 把后台子代理终态写回进度卡:成功=结果摘要(含工件行),
// 失败=错误说明;状态统一置 done,tone 区分成败。kv 原样保留(材料键等持久凭据)。
func (m *Manager) finishBackgroundCard(owner string, card core.ActivityCard, payload core.AgentResultPayload, err error, kv [][2]string) {
@@ -1906,13 +1989,16 @@ func (m *Manager) finishBackgroundCard(owner string, card core.ActivityCard, pay
func (m *Manager) decBackground(convID string) {
m.noticeMu.Lock()
defer m.noticeMu.Unlock()
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) {
+2 -2
View File
@@ -59,7 +59,7 @@ func SystemDefaults() map[string]any {
"read": false, // 跨会话读取项目闸门(提案 2026-09-30-cross-session-reading):关 = 全项目禁读
},
"retrieval": map[string]any{"topK": 5},
"ui": map[string]any{"theme": "dark", "cardflow": false},
"ui": map[string]any{"theme": "dark", "cardflow": true}, // 长产物进卡片默认开(用户 2026-10-11 拍板)
"deliverables": map[string]any{
"style": "modern", // modern / formal / minimal
"accentColor": "", // 空 = 主题默认主色;#RRGGBB 或 RRGGBB
@@ -118,7 +118,7 @@ func Schema() []core.SettingDesc {
{Key: "crossSession.read", Group: "crossSession", Label: "跨会话读取", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "允许本项目会话读取同项目其他会话的对话记录(list_conversations / read_conversation 工具);会话可单独关闭读取或设为私有"},
{Key: "retrieval.topK", Group: "retrieval", Label: "检索命中数", Type: core.SettingNumber, Default: 5, Effect: core.EffectHot},
{Key: "ui.theme", Group: "ui", Label: "主题", Type: core.SettingEnum, Default: "dark", Options: []string{"dark", "light"}, Effect: core.EffectHot},
{Key: "ui.cardflow", Group: "ui", Label: "长结果进右侧卡片", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "开启后报告/纪要/方案等产物型长内容经 submit_card 进收件箱,对话只留结论摘要与锚点"},
{Key: "ui.cardflow", Group: "ui", Label: "长结果进右侧卡片", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "开启后报告/纪要/方案等产物型长内容经 submit_card 进收件箱,对话只留结论摘要与锚点(默认开,用户 2026-10-11 裁定)"},
{Key: "tools.tavily.apiKeys", Group: "tools", Label: "Tavily API Keys(池)", Type: core.SettingSecret, Default: "", Effect: core.EffectRestart, Description: "逗号分隔;配置后注册 tavily_search/web_search 工具(429/401/403 自动轮换下一把)"},
{Key: "tools.tavily.endpoint", Group: "tools", Label: "Tavily 端点", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Description: "空 = 官方 https://api.tavily.com/search;可指向自建代理"},
{Key: "tools.mcp.overrides", Group: "tools", Label: "MCP 项目覆盖", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "JSON 对象。键是服务器名,值可含 enabled 与 exposure(direct 或 deferred)"},