diff --git a/internal/runmgr/material.go b/internal/runmgr/material.go index 93dbc90..fc7d300 100644 --- a/internal/runmgr/material.go +++ b/internal/runmgr/material.go @@ -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) diff --git a/internal/runmgr/material_test.go b/internal/runmgr/material_test.go index b937637..ebb3ba1 100644 --- a/internal/runmgr/material_test.go +++ b/internal/runmgr/material_test.go @@ -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() +} diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index f037255..ce0b423 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -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) { diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 334ab89..3fcd55d 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -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)"},