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:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)"},
|
||||
|
||||
Reference in New Issue
Block a user