feat(channelbridge): 群聊静默窗口合并与回合中续跑(M23)

主人 2026-10-23/24:「要让群聊聊天很自然,很流畅的做法」。群里「一鼓作气打三句」
此前会让她回三次;本片在**每会话车道头部**加一段静默等待,把这几句合成**一轮**回答
(批内每条仍是独立的一条消息,design/15 §2.1 不许拼成一条)。

四条不可走样:

- 🔴 主人私聊**结构性**无窗口:车道分 `newDirectLane`(无 batcher 字段、拿不到定时器
  工厂)与 `newBatchLane`,类型由 actor 元数据(IsOwner() && !IsGroupChat())决定,
  配置无权翻转;
- 🔴 窗口内零丢失:唯一容器是车道队列(takeAll 单消费者取出),合并阶段无新增丢弃点
  (CoalescedDroppedTotal 恒 0);超单批上限的**尾部回插队首**保序、锁内、发车前;
- 🔴 一条批 = 一个 turnID = 一次 loop.Run = 一条助手回复;
- 🔴 不改「回什么」:闸门仍逐条跑(prepareInbound 逐字搬运 M22 的 processFrom),
  被静默的消息进不了批。

实现落点:
- lane.go:direct/batch 两种车道 + front(回插队首)+ takeAll + 停机诚实登记;
- coalesce.go:生效参数解析、滑动窗口 + 绝对上限等待、发车原因与计数;
- coalesce_lane.go:攒批 → 拆分 → 陈旧度/闸门 → 发车;
- coalesce_prepare.go / coalesce_batch.go:processFrom 拆成 prepareInbound + runBatch;
- runtimeconfig/channels_coalesce.go:$DATA_DIR/config/coalesce.json(零新 .env 键);
- gateway/admin_coalesce.go:GET/PUT /api/v1/admin/channels/coalesce(三态补丁 +
  writeAuth + 审计 channels.coalesce + 热生效),routes.go 两行注册(塔已批);
- 前端:渠道总览页一张卡(六个旋钮;主人私聊只给只读说明,不给开关)。

M22 既有用例仍在(lane_test.go 夹具显式关窗以钉住车道本身);stranger_test.go 的那条
按塔裁定 #4 改写:「已有回合在跑 ⇒ 只登记不回」变成排队回答(留 WARN,绝不静默)。

回归:go build/vet/test ./... 全绿;三包 -race 全绿;前端 tsc --noEmit + npm run build
+ 渲染测试 36 项通过。不 push、不重启后端、未改 .env 与 Docs/。
This commit is contained in:
AskaEth committed 2026-10-24 21:11:22 +08:00
1 parent 679c8abf1d
commit ca8577f05b
21 files changed
+3616 -244

No files matched your search

+62 -1
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"errors"
"fmt"
"log/slog"
"path/filepath"
@@ -114,6 +115,12 @@ func (a *app) wireChannels() error {
ownerLane := runtimeconfig.NewOwnerLaneStore(
runtimeconfig.DefaultOwnerLanePath(docDataDir),
logger.WithSource(a.log, "services/channelbridge/ownerlane"))
// 静默窗口合并(M23)的落盘面:$DATA_DIR/config/coalesce.json(零新 env 键,
// design/13 §2.1)。它只承载**调优**(窗口/绝对上限/单批上限/给不给陌生人),
// **不承载身份**,也**翻不开**主人私聊的 direct 车道(那是 actor 元数据决定的类型)。
coalesceStore := runtimeconfig.NewCoalesceStore(
runtimeconfig.DefaultCoalescePath(docDataDir),
logger.WithSource(a.log, "services/channelbridge/coalesce"))
bridge, berr := channelbridge.New(channelbridge.Options{
Log: logger.WithSource(a.log, "services/channelbridge"),
Loop: a.loop,
@@ -168,6 +175,15 @@ func (a *app) wireChannels() error {
MinReplyGapSec: p.MinReplyGapSec,
})
},
// Coalesce 是**静默窗口合并**(M23)的注入面:按车道键取生效窗口参数。
// 车道类型不在这里决定 —— 主人私聊恒为 direct 车道(无窗口),由 actor 元数据
// 判定,配置无权翻转;这里只给 batch 车道(群聊/陌生人私聊)的窗口取值。
Coalesce: channelbridge.CoalesceOptions{
For: func(laneKey string) *runtimeconfig.CoalescePolicy {
p := coalesceStore.PolicyFor(laneKey)
return &p
},
},
// Factory 让面板保存时热替换某平台的适配器而无需重启(design/04 §3.1)。
Factory: factory,
// Channels 让热重载把新适配器写回能力注册表:管理面板(渠道卡片、账号在线
@@ -207,6 +223,7 @@ func (a *app) wireChannels() error {
fallback: factory.fallback,
bridge: bridge,
owner: a.owner,
coalesce: coalesceStore,
log: a.log,
}
// 启动即灌一次每渠道管理员白名单(裁决 06.4.2):不必等面板打开或热重载,
@@ -227,11 +244,55 @@ type runtimeChannels struct {
// owner 是渠道 bridge 判 actor 用的**同一个**活档案指针:读配置时把每渠道管理员
// 白名单灌进它的 adminIDs 层(裁决 06.4.2),面板保存后无需重启即生效。
owner *user.OwnerProfile
log *slog.Logger
// coalesce 是静默窗口合并(M23)的**同一个** store 实例:bridge 每次发车前从它
// 读生效值,因此面板保存后下一次发车即生效(热生效),无需重启。
coalesce *runtimeconfig.CoalesceStore
log *slog.Logger
}
func (r *runtimeChannels) Path() string { return r.path }
// CoalescePath 返回静默窗口配置文件(coalesce.json)的绝对落点,供面板如实回显。
func (r *runtimeChannels) CoalescePath() string {
if r.coalesce == nil {
return ""
}
return r.coalesce.Path()
}
// CoalescePresent 报告静默窗口配置文件是否已存在(面板据此显示「还没配置过」)。
func (r *runtimeChannels) CoalescePresent() bool {
if r.coalesce == nil {
return false
}
return r.coalesce.Present()
}
// CoalesceSnapshot 返回盘上那份静默窗口配置(未 clamp 的原值),供面板回显。
func (r *runtimeChannels) CoalesceSnapshot() runtimeconfig.CoalesceFile {
if r.coalesce == nil {
return runtimeconfig.CoalesceFile{}
}
return r.coalesce.Snapshot()
}
// ApplyCoalesce 应用一份三态补丁:内存与落盘在同一个临界区,桥下一次发车即读到新值
// (store 与 bridge 是**同一个**实例,因此不需要任何重启或重载)。
func (r *runtimeChannels) ApplyCoalesce(patch runtimeconfig.CoalescePatch) (bool, error) {
if r.coalesce == nil {
return false, errors.New("channel coalesce config store not wired")
}
wrote, err := r.coalesce.Apply(patch)
if err != nil {
return wrote, fmt.Errorf("apply coalesce patch: %w", err)
}
if wrote {
r.log.Info("channel coalesce config saved (hot: the bridge reads it on the next departure)",
"path", r.CoalescePath())
}
return wrote, nil
}
func (r *runtimeChannels) Load() map[string][]config.ChannelAccount {
accounts := r.loadAccounts()
r.applyAdminIDs(accounts)
@@ -0,0 +1,343 @@
/**
* 渠道「静默窗口合并」卡(M23)。真源:internal/services/gateway/admin_coalesce.go。
*
* 它回答的是主人的原话「要让群聊聊天很自然,很流畅的做法」:群里「一鼓作气打三句」
* 不该让她回三次 —— 她在群里等一小段静默,把这几句**合并成一轮**回答(批内每条仍是
* 独立的一条消息,绝不拼成一条)。
*
* 三条口径落在文案里,因为它们是这张卡的产品承诺:
* 1. **主人私聊没有这个窗口**:她的私聊一条一条立刻回(`ownerPrivate:"never"` 是后端
* 下发的只读事实,面板**不给**开关 —— 那条车道由身份元数据决定,配置翻不开);
* 2. **越界不拒绝、会夹紧**:规则随读下发(`rules`),面板就地校验,后端仍会 clamp
* 并留 WARN —— 不让主人白跑,也不假装「你配的数就是生效的数」;
* 3. **保存即热生效**:桥每次发车前重读同一份配置,所以 `hot:true` 是事实。
*
* 呈现与取数分层:`CoalesceCardBody` 只吃状态(渲染测试直接喂 fixture),
* `ChannelCoalesceCard` 负责取数 + 表单草稿 + 保存。
*/
import { useCallback, useEffect, useState } from "react";
import { request } from "../../../lib/api/core";
import { Button, Surface, Tag } from "../../../components/primitives";
import { Notice } from "../../components";
import { useAuditStore } from "../../audit";
/** 一组窗口参数(后端 AdminCoalescePolicy 的同形投影)。 */
export interface CoalescePolicyView {
enabled: boolean;
windowMs: number;
maxWaitMs: number;
maxBatchMessages: number;
applyToStrangers: boolean;
strangerWindowMs: number;
}
/** 取值规则(后端 AdminCoalesceRules)。 */
export interface CoalesceRules {
windowMs: [number, number];
maxWaitMs: [number, number];
maxBatchMessages: [number, number];
clamp: string;
flushReasons: string[];
}
/** GET/PUT 的 data 形状(后端 AdminCoalesceView)。 */
export interface CoalesceView {
path: string;
present: boolean;
hot: boolean;
default: CoalescePolicyView;
patch: Record<string, unknown>;
conversations: Record<string, CoalescePolicyView>;
rules: CoalesceRules;
ownerPrivate: string;
}
/** 表单草稿:一律字符串(输入框的原样值),保存时才转数字。 */
export interface CoalesceDraft {
enabled: string;
windowMs: string;
maxWaitMs: string;
maxBatchMessages: string;
applyToStrangers: string;
strangerWindowMs: string;
}
/** 只读事实的文案(渲染测试钉住它必须出现)。 */
export const OWNER_PRIVATE_NOTE =
"主人私聊不受此设置影响:她的私聊没有静默窗口,一条一条立刻回(由身份元数据决定,面板没有开关)";
/** 热生效文案。 */
export const COALESCE_HOT_LABEL = "已热生效(下一次发车就用新值,不用重启后端)";
/** 字段顺序与中文标签(表单项由它驱动,避免手写六段重复代码)。 */
export const COALESCE_FIELDS: ReadonlyArray<{
key: keyof CoalesceDraft;
label: string;
kind: "bool" | "int";
hint: string;
}> = [
{ key: "enabled", label: "启用群聊静默窗口", kind: "bool", hint: "关掉 = 恢复到「到达即回、一条一轮」" },
{ key: "windowMs", label: "静默窗口(毫秒)", kind: "int", hint: "新消息会重置计时(等她把话说完)" },
{ key: "maxWaitMs", label: "绝对上限(毫秒)", kind: "int", hint: "从这一批第一条算起,到点必发车" },
{ key: "maxBatchMessages", label: "单批上限(条)", kind: "int", hint: "超出不是丢弃,是多出来的排成下一批" },
{ key: "applyToStrangers", label: "对陌生人私聊生效", kind: "bool", hint: "关掉 = 陌生人私聊逐条回" },
{ key: "strangerWindowMs", label: "陌生人私聊窗口(毫秒)", kind: "int", hint: "仅在「对陌生人私聊生效」时使用" },
];
/** draftFromView 把生效值铺成草稿。 */
export function draftFromView(view: CoalesceView): CoalesceDraft {
return {
enabled: String(view.default.enabled),
windowMs: String(view.default.windowMs),
maxWaitMs: String(view.default.maxWaitMs),
maxBatchMessages: String(view.default.maxBatchMessages),
applyToStrangers: String(view.default.applyToStrangers),
strangerWindowMs: String(view.default.strangerWindowMs),
};
}
/** parseCoalescePatch 把草稿按**三态**转成补丁:只有与当前生效值不同的键才写。 */
export function parseCoalescePatch(
view: CoalesceView,
draft: CoalesceDraft
): { patch: { default: Record<string, number | boolean> }; errors: Record<string, string>; changed: string[] } {
const errors: Record<string, string> = {};
const out: Record<string, number | boolean> = {};
const changed: string[] = [];
const intIn = (key: string, value: string, lo: number, hi: number): number | null => {
const trimmed = value.trim();
if (!/^\d+$/.test(trimmed)) {
errors[key] = "要填整数(毫秒/条)";
return null;
}
const n = Number(trimmed);
if (n < lo || n > hi) {
errors[key] = `取值范围 ${lo}–${hi}`;
return null;
}
return n;
};
const window = intIn("windowMs", draft.windowMs, view.rules.windowMs[0], view.rules.windowMs[1]);
const maxWait = window === null
? null
: intIn("maxWaitMs", draft.maxWaitMs, Math.max(view.rules.maxWaitMs[0], window), view.rules.maxWaitMs[1]);
const batch = intIn("maxBatchMessages", draft.maxBatchMessages, view.rules.maxBatchMessages[0], view.rules.maxBatchMessages[1]);
const strangerWindow = intIn(
"strangerWindowMs",
draft.strangerWindowMs,
view.rules.windowMs[0],
view.rules.windowMs[1]
);
const enabled = draft.enabled === "true";
const applyToStrangers = draft.applyToStrangers === "true";
const put = (key: string, next: number | boolean, current: number | boolean): void => {
if (next !== current) {
out[key] = next;
changed.push(key);
}
};
if (window !== null) put("windowMs", window, view.default.windowMs);
if (maxWait !== null) put("maxWaitMs", maxWait, view.default.maxWaitMs);
if (batch !== null) put("maxBatchMessages", batch, view.default.maxBatchMessages);
if (strangerWindow !== null) put("strangerWindowMs", strangerWindow, view.default.strangerWindowMs);
put("enabled", enabled, view.default.enabled);
put("applyToStrangers", applyToStrangers, view.default.applyToStrangers);
return { patch: { default: out }, errors, changed };
}
/** fetchCoalesce 读一次配置(面板配置面,writeAuth = owner|admin)。 */
export function fetchCoalesce(): Promise<CoalesceView> {
return request<CoalesceView>("/admin/channels/coalesce");
}
/** saveCoalesce 提交三态补丁(返回**重新读盘**后的真值)。 */
export function saveCoalesce(patch: { default: Record<string, number | boolean> }): Promise<CoalesceView> {
return request<CoalesceView>("/admin/channels/coalesce", {
method: "PUT",
body: JSON.stringify(patch),
});
}
/** BodyProps 是展示半边的输入(全部状态由外面给,便于直接渲染断言)。 */
export interface CoalesceBodyProps {
view: CoalesceView;
draft: CoalesceDraft;
errors: Record<string, string>;
busy: boolean;
receipt: string | null;
savedView: CoalesceView | null;
onDraftChange: (key: keyof CoalesceDraft, value: string) => void;
onSave: () => void;
}
/** CoalesceCardBody 是展示半边:吃状态、不取数(渲染测试直接喂它)。 */
export function CoalesceCardBody({
view,
draft,
errors,
busy,
receipt,
savedView,
onDraftChange,
onSave,
}: CoalesceBodyProps): React.ReactNode {
const hasErrors = Object.keys(errors).length > 0;
const inputCls =
"w-36 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]";
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Tag tone="accent">{COALESCE_HOT_LABEL}</Tag>
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{view.path}</span>
<span className="text-[11px] text-[var(--mu-text-lo)]">
{view.present ? "面板配置已存在" : "面板配置还没写过(运行期走代码默认:2.0s / 6s / 24 条)"}
</span>
</div>
<Notice>{OWNER_PRIVATE_NOTE}</Notice>
{COALESCE_FIELDS.map((field) => (
<div key={field.key} className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)]/60 px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-56 text-xs text-[var(--mu-text-hi)]">{field.label}</span>
{field.kind === "bool" ? (
<select
value={draft[field.key]}
className="h-9 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
onChange={(e) => onDraftChange(field.key, e.target.value)}
>
<option value="true">开</option>
<option value="false">关</option>
</select>
) : (
<input
value={draft[field.key]}
inputMode="numeric"
className={inputCls}
onChange={(e) => onDraftChange(field.key, e.target.value)}
/>
)}
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{field.key}</span>
</div>
<div className="text-[11px] text-[var(--mu-text-lo)]">{field.hint}</div>
{errors[field.key] ? <div className="text-[11px] text-[var(--mu-err)]">⚠ {errors[field.key]}</div> : null}
</div>
))}
<div className="flex flex-wrap items-center gap-2">
<Button variant="primary" disabled={busy || hasErrors} onClick={onSave}>
{busy ? "保存中…" : "保存窗口设置"}
</Button>
{hasErrors ? (
<span className="text-[11px] text-[var(--mu-text-lo)]">有坏值,未提交(后端一个字都不会改)</span>
) : null}
</div>
{receipt ? <Notice>{receipt}</Notice> : null}
{savedView ? (
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-[11px]">
<span className="text-[var(--mu-text-mid)]">保存后回显(后端重新读盘的真值):</span>
<div className="font-mono text-[var(--mu-text-lo)]">
窗口 {savedView.default.windowMs}ms · 上限 {savedView.default.maxWaitMs}ms · 单批{" "}
{savedView.default.maxBatchMessages} 条 · {savedView.default.enabled ? "已启用" : "已关闭"}
</div>
<div className="text-[var(--mu-text-mid)]">{savedView.hot ? COALESCE_HOT_LABEL : "已落盘,但未能热生效"}</div>
</div>
) : null}
{Object.keys(view.conversations).length > 0 ? (
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-[11px]">
<span className="text-[var(--mu-text-mid)]">每会话覆盖(在配置文件里维护,键 = 平台|渠道):</span>
{Object.entries(view.conversations).map(([key, policy]) => (
<div key={key} className="font-mono text-[var(--mu-text-lo)]">
{key} → {policy.enabled ? "启用" : "关闭"} · 窗口 {policy.windowMs}ms · 单批 {policy.maxBatchMessages} 条
</div>
))}
</div>
) : null}
<p className="text-[11px] text-[var(--mu-text-lo)]">
规则:窗口 {view.rules.windowMs[0]}–{view.rules.windowMs[1]}ms、绝对上限 ≥ 窗口且 ≤ {view.rules.maxWaitMs[1]}ms、单批{" "}
{view.rules.maxBatchMessages[0]}–{view.rules.maxBatchMessages[1]} 条;越界值由后端夹紧({view.rules.clamp})并留日志。
发车原因:{view.rules.flushReasons.join(" / ")}。
</p>
</div>
);
}
/** ChannelCoalesceCard 负责取数、表单草稿与保存。 */
export function ChannelCoalesceCard(): React.ReactNode {
const [view, setView] = useState<CoalesceView | null>(null);
const [draft, setDraft] = useState<CoalesceDraft | null>(null);
const [errors, setErrors] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const [fail, setFail] = useState<string | null>(null);
const [receipt, setReceipt] = useState<string | null>(null);
const [savedView, setSavedView] = useState<CoalesceView | null>(null);
const log = useAuditStore((s) => s.log);
const reload = useCallback(() => {
setFail(null);
return fetchCoalesce()
.then((next) => {
setView(next);
setDraft(draftFromView(next));
})
.catch((e: unknown) => setFail(e instanceof Error ? e.message : "读取窗口配置失败"));
}, []);
useEffect(() => {
void reload();
}, [reload]);
const onDraftChange = useCallback((key: keyof CoalesceDraft, value: string) => {
setDraft((prev) => (prev ? { ...prev, [key]: value } : prev));
setReceipt(null);
}, []);
const onSave = useCallback(() => {
if (!view || !draft) return;
const check = parseCoalescePatch(view, draft);
setErrors(check.errors);
if (Object.keys(check.errors).length > 0) return;
if (check.changed.length === 0) {
setReceipt("没有改动(一个键都没写)");
return;
}
setBusy(true);
setFail(null);
saveCoalesce(check.patch)
.then((next) => {
setView(next);
setDraft(draftFromView(next));
setSavedView(next);
setErrors({});
setReceipt(`已保存:${check.changed.join(", ")}。${COALESCE_HOT_LABEL}`);
log("channels.coalesce", "ok");
})
.catch((e: unknown) => {
setFail(e instanceof Error ? e.message : "保存窗口配置失败");
log("channels.coalesce", "error");
})
.finally(() => setBusy(false));
}, [view, draft, log]);
return (
<Surface className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">群聊静默窗口合并</h3>
<Tag tone="neutral">GET / PUT /api/v1/admin/channels/coalesce</Tag>
</div>
{fail ? <Notice tone="err">{fail}</Notice> : null}
{view === null || draft === null ? (
<span className="text-xs text-[var(--mu-text-lo)]">{fail ? "读取失败" : "读取中…"}</span>
) : (
<CoalesceCardBody
view={view}
draft={draft}
errors={errors}
busy={busy}
receipt={receipt}
savedView={savedView}
onDraftChange={onDraftChange}
onSave={onSave}
/>
)}
</Surface>
);
}
@@ -9,6 +9,7 @@ import { PLATFORM_LABEL, PLATFORM_TYPES } from "../channelFields";
import { adapterNameOf, configuredAccountCount, configuredPlatforms, platformOf } from "./channelUtils";
import { channelListState, channelStatus } from "./channelStatus";
import { ChannelLoadError, ChannelStatusTag } from "./channelStatusView";
import { ChannelCoalesceCard } from "./CoalesceCard";
import type { AdapterNav } from "./useAdapterNav";
/** Page ① — aggregate all adapters: connection state, message volume, health.
@@ -79,6 +80,9 @@ export function OverviewPage({ navigate }: { navigate: (n: AdapterNav) => void }
onOpenList={() => navigate({ kind: "page", page: "list" })}
/>
) : null}
{/* 群聊静默窗口合并(M23):窗口/上限这类调优只该在面板改,不该 SSH 改文件。 */}
<ChannelCoalesceCard />
</div>
);
}
@@ -0,0 +1,140 @@
// 群聊静默窗口卡(M23)的**真实渲染**测试。
//
// 它钉住三件**呈现**的事(逻辑那一半在 CoalesceCard.tsx 的导出函数里):
// ① 六个字段都有中文标签 + 一句人话说明(窗口/上限/单批/陌生人/开关);
// ② 主人私聊**只给一行只读说明**(`ownerPrivate:"never"` 的呈现),**不给开关**;
// ③ 越界/非整数就地报错并拦住保存(后端那道 400 仍在,只是不让主人白跑)。
//
// 跑法(与 MemoryTidyTuningCard.render.test.ts 同):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/channels/coalesceCard.render.test.ts \
// --bundle --platform=node --format=cjs \
// --outfile=/tmp/cyrene-coalesce-card.render.test.cjs \
// && node /tmp/cyrene-coalesce-card.render.test.cjs
//
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import {
COALESCE_FIELDS,
COALESCE_HOT_LABEL,
OWNER_PRIVATE_NOTE,
CoalesceCardBody,
draftFromView,
parseCoalescePatch,
type CoalesceView,
} from "./CoalesceCard";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function has(haystack: string, needle: string): boolean {
return haystack.includes(needle);
}
/** fixtureView 造一份 GET 响应形状的数据(代码默认 + 一条会话覆盖)。 */
function fixtureView(overrides: Partial<CoalesceView> = {}): CoalesceView {
return {
path: "/data/config/coalesce.json",
present: true,
hot: true,
default: {
enabled: true,
windowMs: 2000,
maxWaitMs: 6000,
maxBatchMessages: 24,
applyToStrangers: true,
strangerWindowMs: 2000,
},
patch: {},
conversations: {
"telegram|group:-20": {
enabled: false,
windowMs: 2000,
maxWaitMs: 6000,
maxBatchMessages: 24,
applyToStrangers: true,
strangerWindowMs: 2000,
},
},
rules: {
windowMs: [300, 15000],
maxWaitMs: [300, 30000],
maxBatchMessages: [1, 50],
clamp: "clamp",
flushReasons: ["quiet", "max_wait", "all_stale", "off", "stop"],
},
ownerPrivate: "never",
...overrides,
};
}
function render(view: CoalesceView, draftOverride?: Partial<ReturnType<typeof draftFromView>>): string {
const draft = { ...draftFromView(view), ...(draftOverride ?? {}) };
const check = parseCoalescePatch(view, draft);
return renderToStaticMarkup(
createElement(CoalesceCardBody, {
view,
draft,
errors: check.errors,
busy: false,
receipt: null,
savedView: null,
onDraftChange: () => undefined,
onSave: () => undefined,
})
);
}
function main(): void {
const view = fixtureView();
const html = render(view);
// ① 六个字段的中文标签 + 说明都在,且「主人私聊」只有说明、没有开关。
for (const field of COALESCE_FIELDS) {
ok(has(html, field.label), `field label missing: ${field.label}`);
ok(has(html, field.hint), `field hint missing for ${field.key}`);
ok(has(html, field.key), `field key not shown for ${field.key}`);
}
ok(has(html, OWNER_PRIVATE_NOTE), "owner-private read-only note missing");
ok(has(html, "面板没有开关"), "owner-private note must say the panel has no switch for it");
ok(has(html, COALESCE_HOT_LABEL), "hot label missing");
ok(has(html, "/data/config/coalesce.json"), "config path not shown");
ok(has(html, "telegram|group:-20"), "per-conversation override not listed");
ok(has(html, "300–15000ms"), "window rules not shown");
ok(has(html, "quiet"), "flush reasons not shown");
// ② 三态补丁:只有改过的键才写;没改 ⇒ 空补丁。
const unchanged = parseCoalescePatch(view, draftFromView(view));
ok(Object.keys(unchanged.patch.default).length === 0, "unchanged draft must produce an empty patch");
ok(unchanged.changed.length === 0, "unchanged draft must report no changed keys");
const changed = parseCoalescePatch(view, { ...draftFromView(view), windowMs: "3000" });
ok(changed.patch.default.windowMs === 3000, "changed window must land in the patch");
ok(changed.changed.length === 1, `only the changed key may be written, got ${changed.changed.join(",")}`);
// ③ 坏值就地报错,且补丁里不含那个键(不会把一半好一半坏的东西发出去)。
const bad = parseCoalescePatch(view, { ...draftFromView(view), windowMs: "10", maxBatchMessages: "abc" });
ok(bad.errors.windowMs !== undefined, "out-of-range window must be reported");
ok(bad.errors.maxBatchMessages !== undefined, "non-numeric batch must be reported");
ok(bad.patch.default.windowMs === undefined, "invalid window must not be sent");
ok(bad.patch.default.maxBatchMessages === undefined, "invalid batch must not be sent");
// 绝对上限必须 ≥ 窗口(下层规则由面板现算,不能只信后端)。
const waitTooSmall = parseCoalescePatch(view, { ...draftFromView(view), windowMs: "5000", maxWaitMs: "3000" });
ok(waitTooSmall.errors.maxWaitMs !== undefined, "maxWait below the window must be rejected");
const badHtml = render(view, { windowMs: "10" });
ok(has(badHtml, "取值范围"), "invalid draft must render an inline error");
ok(has(badHtml, "有坏值,未提交"), "invalid draft must block the save button");
process.stdout.write(`coalesce card render: ${checks} checks passed\n`);
}
main();
@@ -0,0 +1,475 @@
// channels_coalesce.go 承载**渠道静默窗口合并**(M23)的落盘面(M23 蓝图 §7)。
//
// 它回答的是主人 2026-10-23/24 的原话「要让群聊聊天很自然,很流畅的做法」:群里
// 「一鼓作气打三句」不该让她回三次。窗口/上限这些旋钮属于**调优**,因此按
// design/13 §2.1「`.env` 冻结 ⇒ 新配置走 `$DATA_DIR/config/**` + 代码默认值」落在
// $DATA_DIR/config/coalesce.json,**零新 env 键**。
//
// 本文件是纯存储层:它只负责读写、默认值归一与 clamp,**不做任何行为判定** ——
// 「哪些消息进窗口」「主人私聊为什么没有窗口」的判据在
// internal/services/channelbridge/coalesce*.go(车道类型由 actor 元数据决定,
// **配置无权翻转**:这里既没有「对主人私聊生效」的键,也没有能改车道类型的开关)。
package runtimeconfig
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
const (
// coalesceConfigSubdir 与 owner_lane.json / backthink.json 共用 $DATA_DIR 下的
// config 子目录口径。
coalesceConfigSubdir = "config"
// coalesceFileName 是静默窗口合并旋钮的落盘文件名。
coalesceFileName = "coalesce.json"
// coalesceLogSource 是本模块日志的来源标记(AGENTS §10:禁止无来源裸日志)。
coalesceLogSource = "config/runtimeconfig/coalesce"
// clamp 上下限(M23 蓝图 §4 + 塔 2026-10-24 裁定):窗口 300–15000ms、
// 绝对上限 ≥ 窗口且 ≤ 30000ms、单批 1–50 条。越界值**clamp + WARN**,
// 绝不静默采信一个不可能的值,也绝不因此拦住入站。
CoalesceWindowMinMs = 300
CoalesceWindowMaxMs = 15000
CoalesceMaxWaitMinMs = CoalesceWindowMinMs
CoalesceMaxWaitMaxMs = 30000
CoalesceBatchMin = 1
CoalesceBatchMax = 50
coalesceDefaultWindow = 2000
coalesceDefaultWait = 6000
coalesceDefaultBatch = 24
)
// DefaultCoalescePath 返回静默窗口合并配置文件在 dataDir 下的路径。
func DefaultCoalescePath(dataDir string) string {
return filepath.Join(dataDir, coalesceConfigSubdir, coalesceFileName)
}
// CoalesceConversationKey 把**车道键**(`platform \x00 channelID`,与会话键同源)
// 渲染成落盘/面板用的可读键:NUL 换成 `|`。
//
// 为什么需要这一层:车道键里的分隔符是 NUL(`session.Key` 的唯一实现点决定),
// 直接写进 JSON 会变成 `\u0000` —— 面板的输入框里看不见、复制不出来,主人根本
// 没法给某个群配覆盖。反向不歧义:平台名与渠道 id 都不含 `|`,所以这个渲染是
// 单射的;桥只按「同一个函数」把车道键映射成配置键,绝不在别处另拼一份。
func CoalesceConversationKey(laneKey string) string {
return strings.ReplaceAll(laneKey, "\x00", "|")
}
// CoalescePolicy 是一组**生效**的静默窗口参数(已归一、已 clamp)。
//
// 零值**不是**「关闭」的等价物(Enabled=false 才是):读盘缺失时走
// DefaultCoalescePolicy,绝不因为「没写这个键」就把特性当成关闭。
type CoalescePolicy struct {
// Enabled 是群聊窗口的总开关;false ⇒ 行为逐字回到 M22(到达即投递、一条一轮)。
Enabled bool `json:"enabled"`
// WindowMs 是**静默窗口**:滑动语义,新消息重置计时(「等她把话说完」)。
WindowMs int `json:"windowMs"`
// MaxWaitMs 是**绝对上限**,锚定在批内第一条的到达时刻:一个不停说话的群
// 因此不会让计时永不归零(宁可晚回,不许不回)。
MaxWaitMs int `json:"maxWaitMs"`
// MaxBatchMessages 是单批消息条数上限;超出的**不是丢弃**,是由车道回插队首
// 成为下一批(见 channelbridge/coalesce.go 的 split)。
MaxBatchMessages int `json:"maxBatchMessages"`
// ApplyToStrangers 决定陌生人私聊是否也走窗口;false ⇒ 陌生人私聊逐条跑。
ApplyToStrangers bool `json:"applyToStrangers"`
// StrangerWindowMs 是陌生人私聊的独立窗口(v1 与 WindowMs 同值,纯预留:
// 蓝图 §3 明确「现状没有证据支撑两个值」,先留字段避免日后改结构)。
StrangerWindowMs int `json:"strangerWindowMs"`
}
// DefaultCoalescePolicy 返回代码默认值(也是面板「留空 ⇒ 跟随」的那一档)。
func DefaultCoalescePolicy() CoalescePolicy {
return CoalescePolicy{
Enabled: true,
WindowMs: coalesceDefaultWindow,
MaxWaitMs: coalesceDefaultWait,
MaxBatchMessages: coalesceDefaultBatch,
ApplyToStrangers: true,
StrangerWindowMs: coalesceDefaultWindow,
}
}
// CoalescePolicyPatch 是**三态补丁**:键缺位(nil)= 不动;有值 = 采用。
// `null` 在文件层没有清除语义(每个键都有代码默认值兜底),因此无需 Clear 集合。
type CoalescePolicyPatch struct {
Enabled *bool `json:"enabled,omitempty"`
WindowMs *int `json:"windowMs,omitempty"`
MaxWaitMs *int `json:"maxWaitMs,omitempty"`
MaxBatchMessages *int `json:"maxBatchMessages,omitempty"`
ApplyToStrangers *bool `json:"applyToStrangers,omitempty"`
StrangerWindowMs *int `json:"strangerWindowMs,omitempty"`
}
// apply 把补丁里**出现过的**键叠到 p 上(缺位键保持 p 的原值)。
func (patch CoalescePolicyPatch) apply(p CoalescePolicy) CoalescePolicy {
if patch.Enabled != nil {
p.Enabled = *patch.Enabled
}
if patch.WindowMs != nil {
p.WindowMs = *patch.WindowMs
}
if patch.MaxWaitMs != nil {
p.MaxWaitMs = *patch.MaxWaitMs
}
if patch.MaxBatchMessages != nil {
p.MaxBatchMessages = *patch.MaxBatchMessages
}
if patch.ApplyToStrangers != nil {
p.ApplyToStrangers = *patch.ApplyToStrangers
}
if patch.StrangerWindowMs != nil {
p.StrangerWindowMs = *patch.StrangerWindowMs
}
return p
}
// merge 返回自身与后到补丁的合并(后者出现过的键优先)。写盘时用它保留
// 「这次没提到的键」的旧值 —— 三态里的「不动」在文件层就落成这个合并。
func (patch CoalescePolicyPatch) merge(next CoalescePolicyPatch) CoalescePolicyPatch {
out := patch
if next.Enabled != nil {
out.Enabled = next.Enabled
}
if next.WindowMs != nil {
out.WindowMs = next.WindowMs
}
if next.MaxWaitMs != nil {
out.MaxWaitMs = next.MaxWaitMs
}
if next.MaxBatchMessages != nil {
out.MaxBatchMessages = next.MaxBatchMessages
}
if next.ApplyToStrangers != nil {
out.ApplyToStrangers = next.ApplyToStrangers
}
if next.StrangerWindowMs != nil {
out.StrangerWindowMs = next.StrangerWindowMs
}
return out
}
// Empty 报告补丁是否一个键都没有(写盘时据此跳过空改写)。
func (patch CoalescePolicyPatch) Empty() bool {
return patch.Enabled == nil && patch.WindowMs == nil && patch.MaxWaitMs == nil &&
patch.MaxBatchMessages == nil && patch.ApplyToStrangers == nil &&
patch.StrangerWindowMs == nil
}
// CoalesceFile 是 coalesce.json 的形状:一个全局 default 加一份每会话覆盖表
// (键 = CoalesceConversationKey(车道键))。
type CoalesceFile struct {
Default CoalescePolicyPatch `json:"default"`
Conversations map[string]CoalescePolicyPatch `json:"conversations,omitempty"`
}
// EffectiveDefault 返回「代码默认 + 盘上 default 段」归一后的**生效值**(已 clamp)。
// 面板据此显示「当前值」,而 Values() 显示「你写进去的键」—— 两者刻意分开:
// 一个是运行期事实,一个是盘上原文。
func (f CoalesceFile) EffectiveDefault() CoalescePolicy {
p, _ := ClampCoalescePolicy(f.Default.apply(DefaultCoalescePolicy()))
return p
}
// EffectiveConversation 返回某条会话覆盖的生效值:全局生效值 → 该会话覆盖 → clamp。
func (f CoalesceFile) EffectiveConversation(key string) (CoalescePolicy, bool) {
conv, ok := f.Conversations[key]
if !ok {
return CoalescePolicy{}, false
}
p, _ := ClampCoalescePolicy(conv.apply(f.EffectiveDefault()))
return p, true
}
// Values 返回该补丁里**出现过的**键(键缺位 ⇒ 不出现),供面板「留空 ⇒ 跟随默认」
// 如实回显。值是该键在盘上的原值(未经 clamp)。
func (patch CoalescePolicyPatch) Values() map[string]any {
out := map[string]any{}
if patch.Enabled != nil {
out["enabled"] = *patch.Enabled
}
if patch.WindowMs != nil {
out["windowMs"] = *patch.WindowMs
}
if patch.MaxWaitMs != nil {
out["maxWaitMs"] = *patch.MaxWaitMs
}
if patch.MaxBatchMessages != nil {
out["maxBatchMessages"] = *patch.MaxBatchMessages
}
if patch.ApplyToStrangers != nil {
out["applyToStrangers"] = *patch.ApplyToStrangers
}
if patch.StrangerWindowMs != nil {
out["strangerWindowMs"] = *patch.StrangerWindowMs
}
return out
}
// ClampCoalescePolicy 把一组参数夹进可运行区间,并返回**被改动过**的说明
// (调用方按 WARN 落地:越界不是错误,但必须留痕,否则「我配了 100ms」会变成
// 一个没人知道的 300ms)。
func ClampCoalescePolicy(p CoalescePolicy) (CoalescePolicy, []string) {
var notes []string
if p.WindowMs != 0 && p.WindowMs < CoalesceWindowMinMs {
notes = append(notes, fmt.Sprintf("windowMs=%d 太小,取 %d", p.WindowMs, CoalesceWindowMinMs))
p.WindowMs = CoalesceWindowMinMs
}
if p.WindowMs > CoalesceWindowMaxMs {
notes = append(notes, fmt.Sprintf("windowMs=%d 太大,取 %d", p.WindowMs, CoalesceWindowMaxMs))
p.WindowMs = CoalesceWindowMaxMs
}
if p.WindowMs == 0 {
// 蓝图 §4:`0` 与 enabled:false 同义(「窗口 0 毫秒」不可能是有意的调优)。
notes = append(notes, "windowMs=0 视为关闭静默窗口")
p.Enabled = false
p.WindowMs = coalesceDefaultWindow
}
if p.StrangerWindowMs <= 0 {
p.StrangerWindowMs = p.WindowMs
}
if p.StrangerWindowMs < CoalesceWindowMinMs || p.StrangerWindowMs > CoalesceWindowMaxMs {
notes = append(notes, fmt.Sprintf("strangerWindowMs=%d 越界,取 windowMs=%d", p.StrangerWindowMs, p.WindowMs))
p.StrangerWindowMs = p.WindowMs
}
if p.MaxWaitMs < p.WindowMs {
notes = append(notes, fmt.Sprintf("maxWaitMs=%d 小于窗口,取 %d", p.MaxWaitMs, p.WindowMs))
p.MaxWaitMs = p.WindowMs
}
if p.MaxWaitMs > CoalesceMaxWaitMaxMs {
notes = append(notes, fmt.Sprintf("maxWaitMs=%d 太大,取 %d", p.MaxWaitMs, CoalesceMaxWaitMaxMs))
p.MaxWaitMs = CoalesceMaxWaitMaxMs
}
if p.MaxBatchMessages < CoalesceBatchMin {
notes = append(notes, fmt.Sprintf("maxBatchMessages=%d 太小,取 %d", p.MaxBatchMessages, CoalesceBatchMin))
p.MaxBatchMessages = CoalesceBatchMin
}
if p.MaxBatchMessages > CoalesceBatchMax {
notes = append(notes, fmt.Sprintf("maxBatchMessages=%d 太大,取 %d", p.MaxBatchMessages, CoalesceBatchMax))
p.MaxBatchMessages = CoalesceBatchMax
}
return p, notes
}
// ValidateCoalescePatch 拒绝那些**不是调优、而是敲错**的补丁(负数;类型错误已由
// JSON 解码挡掉)。返回错误 ⇒ 调用方回 400 且**盘上一个字节都不动**。
func ValidateCoalescePatch(p CoalescePolicyPatch) error {
fields := []struct {
name string
v *int
}{
{"windowMs", p.WindowMs},
{"maxWaitMs", p.MaxWaitMs},
{"maxBatchMessages", p.MaxBatchMessages},
{"strangerWindowMs", p.StrangerWindowMs},
}
for _, f := range fields {
if f.v != nil && *f.v < 0 {
return fmt.Errorf("%s 不能为负数(%d)", f.name, *f.v)
}
}
return nil
}
// CoalescePatch 是 PUT 请求体的形状(三态):`default` 缺位 ⇒ 全局不动、
// 出现过的键采用;`conversations` 里出现过的键逐键采用,值为 `null` ⇒
// **删掉**该会话覆盖(回到全局 default),没提到的会话一概不动。
type CoalescePatch struct {
Default *CoalescePolicyPatch `json:"default,omitempty"`
Conversations map[string]*CoalescePolicyPatch `json:"conversations,omitempty"`
}
// Empty 报告补丁是否什么都没提(面板「没改任何键就点保存」不该在盘上留一次改写)。
func (p CoalescePatch) Empty() bool {
if p.Default != nil && !p.Default.Empty() {
return false
}
return len(p.Conversations) == 0
}
// CoalesceStore 是静默窗口旋钮的读写面:内存缓存 + 原子落盘。它可安全并发使用。
// 读缺失文件、坏文件都退回默认值(绝不因为配置坏了而阻断入站),但留带来源的 WARN。
type CoalesceStore struct {
path string
log *slog.Logger
mu sync.RWMutex
cfg CoalesceFile
}
// NewCoalesceStore 构造 store 并尽力加载已有文件。path 为空或文件缺失时走默认值,
// 不返回错误:配置坏了只影响窗口取值,绝不该拦住渠道启动。
func NewCoalesceStore(path string, log *slog.Logger) *CoalesceStore {
if log == nil {
log = slog.Default()
}
s := &CoalesceStore{
path: path,
log: logger.WithSource(log, coalesceLogSource),
cfg: CoalesceFile{Default: CoalescePolicyPatch{}, Conversations: map[string]CoalescePolicyPatch{}},
}
s.load()
return s
}
// Path 返回这份配置的落点(面板如实回显「改的是哪个文件」用)。
func (s *CoalesceStore) Path() string { return s.path }
// Present 报告配置文件是否已存在(false = 还没写过,运行期正走代码默认)。
func (s *CoalesceStore) Present() bool {
if s.path == "" {
return false
}
_, err := os.Stat(s.path)
return err == nil
}
// load 尽力从磁盘读入配置。文件不存在是正常情况(首次运行),静默走默认值;
// 其它读/解析错误留 WARN 后仍走默认值。
func (s *CoalesceStore) load() {
if s.path == "" {
return
}
raw, err := os.ReadFile(s.path)
if err != nil {
if !os.IsNotExist(err) {
s.log.Warn("coalesce config unreadable, using defaults", "path", s.path, "error", err)
}
return
}
var f CoalesceFile
if err := json.Unmarshal(raw, &f); err != nil {
s.log.Warn("coalesce config invalid, using defaults", "path", s.path, "error", err)
return
}
if f.Conversations == nil {
f.Conversations = map[string]CoalescePolicyPatch{}
}
s.mu.Lock()
s.cfg = f
s.mu.Unlock()
s.logClamp(f)
}
// logClamp 把 disk 上那份的越界值一次性记进日志(每次 load/Set 一次,不是每次发车)。
func (s *CoalesceStore) logClamp(f CoalesceFile) {
if _, notes := ClampCoalescePolicy(f.Default.apply(DefaultCoalescePolicy())); len(notes) > 0 {
s.log.Warn("coalesce default clamped", "path", s.path, "notes", strings.Join(notes, "; "))
}
for key, patch := range f.Conversations {
if _, notes := ClampCoalescePolicy(patch.apply(DefaultCoalescePolicy())); len(notes) > 0 {
s.log.Warn("coalesce conversation override clamped",
"path", s.path, "conversation", key, "notes", strings.Join(notes, "; "))
}
}
}
// PolicyFor 返回某条车道键**生效**的窗口参数:代码默认 → 全局 default → 该会话覆盖,
// 逐键叠完后 clamp。它永不返回「未设置」:调用方永远拿到一个可运行的值。
//
// 它读的是内存(不碰磁盘),因此面板保存后**下一次发车**就生效(热生效),
// 也与入站路径一样不会被一次落盘抖动卡住。
func (s *CoalesceStore) PolicyFor(laneKey string) CoalescePolicy {
s.mu.RLock()
def := s.cfg.Default
conv, ok := s.cfg.Conversations[CoalesceConversationKey(laneKey)]
s.mu.RUnlock()
p, _ := ClampCoalescePolicy(def.apply(DefaultCoalescePolicy()))
if ok {
p, _ = ClampCoalescePolicy(conv.apply(p))
}
return p
}
// Snapshot 返回配置的一份拷贝(**盘上原值**,未经 clamp),供面板/测试读取。
// 与 PolicyFor 的区别是刻意的:面板要如实回显「你写进去的是什么」,
// 而不是「运行期把它夹成了什么」。
func (s *CoalesceStore) Snapshot() CoalesceFile {
s.mu.RLock()
defer s.mu.RUnlock()
out := CoalesceFile{Default: s.cfg.Default, Conversations: make(map[string]CoalescePolicyPatch, len(s.cfg.Conversations))}
for k, v := range s.cfg.Conversations {
out.Conversations[k] = v
}
return out
}
// Apply 应用一份三态补丁:出现过的键采用,缺位的键保持原值;conversations 里
// 值为 nil 的键 = **删掉**该会话覆盖(回到全局 default)。内存与落盘在同一把锁内,
// 因此并发写入不会互相覆盖,也不会留下半份文件。返回 (是否真的改了盘上的字节)。
//
// 校验(ValidateCoalescePatch)由调用方在**进本函数之前**做完:走到这里意味着
// 「这份补丁合法」,落盘因此不会出现「写了一半才发现非法」。
func (s *CoalesceStore) Apply(patch CoalescePatch) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
before, _ := json.Marshal(s.cfg)
if patch.Default != nil {
s.cfg.Default = s.cfg.Default.merge(*patch.Default)
}
if s.cfg.Conversations == nil {
s.cfg.Conversations = map[string]CoalescePolicyPatch{}
}
for key, conv := range patch.Conversations {
if conv == nil {
delete(s.cfg.Conversations, key)
continue
}
s.cfg.Conversations[key] = s.cfg.Conversations[key].merge(*conv)
}
after, _ := json.Marshal(s.cfg)
if string(before) == string(after) {
return false, nil
}
if err := s.saveLocked(); err != nil {
return false, fmt.Errorf("runtimeconfig: save coalesce config %s: %w", s.path, err)
}
return true, nil
}
// saveLocked 原子写出当前配置(临时文件 + rename)。调用方**必须持有 s.mu**。
// 0600 与 owner_lane.json 同口径:这份文件只被后端自己读写。
func (s *CoalesceStore) saveLocked() error {
if s.path == "" {
return nil
}
dir := filepath.Dir(s.path)
if dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("mkdir: %w", err)
}
}
raw, err := json.MarshalIndent(s.cfg, "", " ")
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
tmp, err := os.CreateTemp(dir, filepath.Base(s.path)+".*.tmp")
if err != nil {
return fmt.Errorf("create temp in %s: %w", dir, err)
}
tmpName := tmp.Name()
if err := tmp.Chmod(0o600); err != nil {
_ = tmp.Close()
_ = os.Remove(tmpName)
return fmt.Errorf("chmod %s: %w", tmpName, err)
}
if _, err := tmp.Write(raw); err != nil {
_ = tmp.Close()
_ = os.Remove(tmpName)
return fmt.Errorf("write %s: %w", tmpName, err)
}
if err := tmp.Close(); err != nil {
_ = os.Remove(tmpName)
return fmt.Errorf("close %s: %w", tmpName, err)
}
if err := os.Rename(tmpName, s.path); err != nil {
_ = os.Remove(tmpName)
return fmt.Errorf("rename: %w", err)
}
return nil
}
@@ -23,6 +23,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/transcode"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/events"
@@ -148,6 +149,13 @@ type Options struct {
// 就丢弃不补答(WARN + 计数 + 审计,绝不静默)。零或负数取
// defaultLaneStaleAfter(那里写了取值依据)。主人私聊与非聊天事件不受该闸约束。
LaneStaleAfter time.Duration
// Coalesce 是**静默窗口合并**(M23)的注入面:按车道键取生效窗口参数 +
// 可注入的定时器工厂。零值 = 走代码默认(群聊窗口 2.0s / 绝对上限 6s / 单批 24 条),
// 也就是「装配方忘了接线也有合理行为」,而不是把特性静默关掉。
//
// 🔴 它**不能**改变车道类型:主人私聊永远是 direct 车道(无窗口、无定时器),
// 由 actor 元数据决定,配置无权翻转(见 lane.go 的 newDirectLane/newBatchLane)。
Coalesce CoalesceOptions
// OwnerLaneFor 是 owner 车道结构化旋钮(design/18 §11「乙 = C」)的**读取**接缝:
// 按 (platform, groupID) 返回该群生效的参与策略,返回 nil 表示没有覆盖
// (桥用自己的默认档:不对她说就不开口)。为 nil 时全部走默认档 + 桥内内存覆盖。
@@ -338,6 +346,13 @@ type Bridge struct {
// 读循环只投递不跑回合;同一会话内串行 FIFO、不同会话并行;超龄与背压丢弃都留痕。
// 只在 enqueueInbound 里与 mu 同时出现,锁顺序恒为 mu → lanes.mu。
lanes *laneSet
// coalesceFor 是静默窗口参数(M23)的读取接缝(面板配置文件 → 生效值);
// coalesceLog 是窗口引擎的子功能日志器(source=services/channelbridge/coalesce);
// coalesceStats 是窗口/发车/续跑的计数面(CoalesceStats() 的只读出口)。
// 三者的类型与语义见 coalesce.go;它们只被 batch 车道的头部用到。
coalesceFor func(laneKey string) *runtimeconfig.CoalescePolicy
coalesceLog *slog.Logger
coalesceStats *coalesceCounters
}
// New 依据 opts 构建 Bridge。当必需依赖缺失时返回错误,
@@ -414,6 +429,11 @@ func New(opts Options) (*Bridge, error) {
cancel: func() {},
sem: make(chan struct{}, maxConcurrent),
// 每会话车道:深度与陈旧度阈值可经 Options 覆盖(零值取 lane.go 里的默认值)。
lanes: newLaneSet(opts.LaneDepth, opts.LaneStaleAfter, log),
// 定时器工厂来自 Options.Coalesce.After(M23 测试缝);direct 车道拿不到它。
lanes: newLaneSet(opts.LaneDepth, opts.LaneStaleAfter, opts.Coalesce.After, log),
// 静默窗口合并(M23):参数读取接缝 + 独立日志来源 + 计数面。
coalesceFor: opts.Coalesce.For,
coalesceLog: logger.WithSource(log, coalesceLogSource),
coalesceStats: &coalesceCounters{},
}, nil
}
+252
View File
@@ -0,0 +1,252 @@
package channelbridge
// 本文件承载**静默窗口合并**(M23)的窗口引擎:生效参数的解析、等待(滑动窗口 +
// 绝对上限)、发车原因与计数。它回答主人 2026-10-23/24 的原话「要让群聊聊天很自然,
// 很流畅的做法」——群里「一鼓作气打三句」不该让她回三次,也不该让她等很久才开口。
//
// 落点(M23 蓝图 §2 候选 C):**车道工作者头部**,也就是 `enqueueInbound` 之后、
// 回合之前。理由:车道本身就是那条会话唯一的缓冲,窗口只是「何时把队列里的东西发车」;
// 不新造第二套缓冲,也不会把「本来就会被流控丢掉的消息」拉进窗口。
//
// 三条结构保证(不是靠自觉):
//
// ① **窗口内零丢失**:消息只存在于车道队列(lane.ch / lane.spill / lane.front),
// 入队后除了车道的单消费者 takeAll 没有任何移除路径;本文件与 coalesce_*.go
// 的合并阶段没有「跳过某条」的分支(超量的尾部回插队首,见 requeueFront)。
// ② **一条批 = 一个 turnID = 一次 loop.Run = 一条助手回复**,但批内 N 条仍是
// **各自独立的 user 行**(design/15 §2.1 明文禁止把多条拼成一条)。
// ③ **主人私聊没有窗口**:那条车道是 direct 车道(`lane.batcher == nil`),
// 连定时器工厂都拿不到(见 lane.go 的 newDirectLane / newBatchLane),
// 类型由 actor 元数据决定,**配置无权翻转**。
//
// 时间基准:窗口用**到达时刻**(Bridge.now,可注入),**不**用 msg.Timestamp ——
// qq/telegram/kook 是平台秒级时间、weixin 是本机时钟,精度与漂移都不可靠。
// 陈旧度闸仍按 M22 口径用到达时刻判,两者互不掺杂。
import (
"context"
"sync/atomic"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
const (
// coalesceLogSource 是窗口引擎的子功能日志来源(AGENTS §10:日志必须标来源)。
// 车道自身(背压/陈旧度)的来源归 M22(services/channelbridge/lane)。
coalesceLogSource = "services/channelbridge/coalesce"
// 发车原因(`flush_reason` 的闭集,进日志与计数):
// quiet —— 静默窗口到点(「她把话说完了」);
// max_wait —— 绝对上限到点(「说得没完没了也得先回一句」);
// stop —— 桥停止,未发车的批如实登记丢弃;
// all_stale —— 批内逐条都超龄(M22 口径:太旧不补答),整批不发车;
// off —— 该车道/该会话没有窗口(direct 车道,或开关关闭),到达即发车。
coalesceFlushQuiet = "quiet"
coalesceFlushMaxWait = "max_wait"
coalesceFlushStop = "stop"
coalesceFlushAllStale = "all_stale"
coalesceFlushOff = "off"
)
// batchWindow 是**只有 batch 车道**才持有的窗口状态:它持有定时器工厂。
//
// 这是 M23 最硬的一条结构约束(红线级):direct 车道(主人私聊)的类型里**没有**
// 这个字段,也没有任何能拿到它的路径 ⇒「主人私聊不会建窗口」不是 `if enabled`
// 判出来的结果,而是**类型上不可达**。配置因此无权把它翻成 batch。
type batchWindow struct {
// after 是定时器工厂(默认 time.After)。测试注入手动触发的实现,使
// 「2.0s 静默」与「6s 绝对上限」都能被确定性地推进,且能数出调用次数 ——
// TestOwnerPrivateNeverCreatesWindow / TestOwnerPrivateIgnoresEnabledConfig
// 断言的正是「这个工厂一次都没被调用」。
after func(time.Duration) <-chan time.Time
}
// coalesceWindow 是一条车道**本次发车**实际使用的窗口参数(已归一为 Duration)。
type coalesceWindow struct {
window time.Duration
maxWait time.Duration
maxBatch int
}
// CoalesceOptions 是静默窗口合并的注入面(M23)。
//
// 时钟走 Options.Now(与陈旧度闸同一条注入时钟:一片里有两个时间源必然漂移)。
type CoalesceOptions struct {
// For 按**车道键**返回该会话生效的窗口参数;nil 表示未接线(走代码默认)。
// 每次发车前重读它 ⇒ 面板保存后热生效,不需要重启后端。
//
// 这里刻意用 runtimeconfig 的策略**类型**(而不是桥内再定义一份):默认值与 clamp
// 只有一处实现(`runtimeconfig.DefaultCoalescePolicy` / `ClampCoalescePolicy`),
// 面板读到的规则与运行期真正生效的区间因此不可能分叉。桥只依赖这个**值类型**,
// 不持有 store、不做 I/O(store 由组合根注入)。
For func(laneKey string) *runtimeconfig.CoalescePolicy
// After 是定时器工厂(测试缝)。nil ⇒ time.After。
After func(time.Duration) <-chan time.Time
}
// coalesceCounters 是窗口引擎的进程内计数(原子,读侧无需锁)。
type coalesceCounters struct {
windowsCreated atomic.Int64
batchesTotal atomic.Int64
batchMessagesTotal atomic.Int64
flushQuiet atomic.Int64
flushMaxWait atomic.Int64
batchesSplit atomic.Int64
continuations atomic.Int64
coalescedDropped atomic.Int64
abandonedAtStop atomic.Int64
batchesAllStale atomic.Int64
}
// CoalesceStats 是窗口引擎的计数快照(M23 蓝图 §8)。它给进程内调用方(测试、
// 将来的运维探针)读同一组数字的窄口,**不新开 HTTP 端点**(那是契约变更,归塔)。
//
// CoalescedDroppedTotal **必须恒为 0**:合并阶段没有任何丢弃点 —— 它存在的意义
// 就是让「有人不小心加了一条 continue」有一个能被断言的出口。
type CoalesceStats struct {
// WindowsCreated 是「真正等过窗口」的批数(direct 车道与开关关闭时恒不增加)。
WindowsCreated int64
// BatchesTotal 是发过车的批数(含单条批)。
BatchesTotal int64
// BatchMessagesTotal 是这些批里的入站条数合计。
BatchMessagesTotal int64
// FlushQuietTotal / FlushMaxWaitTotal 按发车原因分开计数。
FlushQuietTotal int64
FlushMaxWaitTotal int64
// BatchesSplitTotal 是因单批上限被拆开、尾部回插队首的次数。
BatchesSplitTotal int64
// ContinuationsTotal 是「批内首条在该会话上一轮结束之前就已到达」的批数(⑤)。
ContinuationsTotal int64
// CoalescedDroppedTotal 必须恒为 0。
CoalescedDroppedTotal int64
// AbandonedAtStopTotal 是桥停止时未发车、随进程一起作废的入站条数(诚实登记)。
AbandonedAtStopTotal int64
// BatchesAllStaleTotal 是整批都超龄、因此一条都没跑的批数。
BatchesAllStaleTotal int64
}
// CoalesceStats 返回窗口引擎计数的快照。
func (b *Bridge) CoalesceStats() CoalesceStats {
c := b.coalesceStats
if c == nil {
return CoalesceStats{}
}
return CoalesceStats{
WindowsCreated: c.windowsCreated.Load(),
BatchesTotal: c.batchesTotal.Load(),
BatchMessagesTotal: c.batchMessagesTotal.Load(),
FlushQuietTotal: c.flushQuiet.Load(),
FlushMaxWaitTotal: c.flushMaxWait.Load(),
BatchesSplitTotal: c.batchesSplit.Load(),
ContinuationsTotal: c.continuations.Load(),
CoalescedDroppedTotal: c.coalescedDropped.Load(),
AbandonedAtStopTotal: c.abandonedAtStop.Load(),
BatchesAllStaleTotal: c.batchesAllStale.Load(),
}
}
// coalesceWindowFor 解析某条车道**此刻**生效的窗口:代码默认 → 注入的 store
// (面板配置文件)→ 归一/夹紧 → 按会话类型决定「这条车道要不要窗口」。
//
// 返回 ok=false 的三种情形都表示「到达即发车」(行为回到 M22):
// - 开关关闭(`enabled:false`,也是「回退到步 1 行为」的唯一旋钮);
// - 陌生人私聊且 `applyToStrangers:false`;
// - 配置算出窗口 0(归一后不会出现,防御性兜底)。
//
// 🔴 它**不**决定车道类型:主人私聊根本不进这里(direct 车道没有 batcher)。
func (b *Bridge) coalesceWindowFor(laneKey string, strangerPrivate bool) (coalesceWindow, bool) {
p := runtimeconfig.DefaultCoalescePolicy()
if b.coalesceFor != nil {
if got := b.coalesceFor(laneKey); got != nil {
p = *got
}
}
p, _ = runtimeconfig.ClampCoalescePolicy(p)
if !p.Enabled {
return coalesceWindow{}, false
}
if strangerPrivate && !p.ApplyToStrangers {
return coalesceWindow{}, false
}
windowMs := p.WindowMs
if strangerPrivate && p.StrangerWindowMs > 0 {
windowMs = p.StrangerWindowMs
}
w := coalesceWindow{
window: time.Duration(windowMs) * time.Millisecond,
maxWait: time.Duration(p.MaxWaitMs) * time.Millisecond,
maxBatch: p.MaxBatchMessages,
}
if w.window <= 0 || w.maxWait < w.window || w.maxBatch <= 0 {
return coalesceWindow{}, false
}
return w, true
}
// coalesceWait 在车道头部等一段静默期,把等待期间到达的消息**全部**并进这一批,
// 并返回发车原因。
//
// 语义(M23 蓝图 §4):
// - **滑动**:新消息把计时重置到 w.window(「等她把话说完」);
// - **绝对上限**:w.maxWait 锚定在批内**第一条**的到达时刻 —— 一个不停说话的群
// 因此不会让计时永不归零(宁可晚回,不许不回)。
//
// 它只做「等」与「收」:不判内容、不丢弃、不改顺序(收进来的顺序就是到达顺序:
// 车道 FIFO ⇒ takeAll 的切片本身有序)。
func (b *Bridge) coalesceWait(ctx context.Context, l *lane, held []laneItem, w coalesceWindow) ([]laneItem, string) {
items := held
anchor := items[0].arrivedAt
lastArrival := items[len(items)-1].arrivedAt
b.coalesceStats.windowsCreated.Add(1)
for {
now := b.now()
// 绝对上限先判:到点必发车(哪怕静默窗口因为不停有新消息而一直没归零)。
maxLeft := w.maxWait - now.Sub(anchor)
if maxLeft <= 0 {
return items, coalesceFlushMaxWait
}
quietLeft := w.window - now.Sub(lastArrival)
if quietLeft <= 0 {
return items, coalesceFlushQuiet
}
wait := quietLeft
if maxLeft < wait {
wait = maxLeft
}
select {
case <-l.batcher.after(wait):
more := b.takeAll(l)
if len(more) == 0 {
// 没有新消息:到点即发车,原因由「哪条上限先到」决定。
if w.maxWait-b.now().Sub(anchor) <= w.window-b.now().Sub(lastArrival) {
return items, coalesceFlushMaxWait
}
return items, coalesceFlushQuiet
}
items = append(items, more...)
lastArrival = items[len(items)-1].arrivedAt
case item := <-l.ch:
items = append(items, item)
if more := b.takeAll(l); len(more) > 0 {
items = append(items, more...)
}
lastArrival = items[len(items)-1].arrivedAt
case <-ctx.Done():
return items, coalesceFlushStop
}
}
}
// noteFlushReason 把发车原因记进对应的计数(未知原因不计数:宁可少一个数,
// 也不要把它归到一个「看起来像」的桶里)。
func (b *Bridge) noteFlushReason(reason string) {
switch reason {
case coalesceFlushQuiet:
b.coalesceStats.flushQuiet.Add(1)
case coalesceFlushMaxWait:
b.coalesceStats.flushMaxWait.Add(1)
case coalesceFlushAllStale:
b.coalesceStats.batchesAllStale.Add(1)
}
}
@@ -0,0 +1,162 @@
package channelbridge
// coalesce_batch.go 承载「**一条批 = 一次回合**」的落地:一个 turnID、一次 loop.Run、
// 一条助手回复,但批内 N 条仍是**各自独立的 user 行**(design/15 §2.1 明文禁止把多条
// 合并成一条:open-webui 曾把多条拼成一条,被当 bug 修过)。
//
// 形状(对 M22 的 runInboundTurn 逐字保持,只是把「一条」换成「一对多」):
//
// [来源/边界系统行(首条)] …转录… [user₁, user₂, …, userₙ] ← 模型可见
// chat_messages: user×N(同一 turnID、到达顺序)+ assistant×1(同 turnID)
//
// 三条口径:
// 1. **转录播种在落库之前**(与 M22 同序):因此本批自己的行绝不会同时出现在转录与
// 当次消息里;批内每条都只出现一次(不重)。
// 2. **回复路由取批内最后一条**(`sendReply` 只认 Platform/ChannelID/GroupID/SenderID):
// 同一车道的消息属于同一个对话,用最新那条的路由字段回话是唯一不会回错群的口径。
// 3. **附属内容各自挂各自的消息**:每条消息的附件/图片/语音只挂它自己那条 user 行,
// 绝不合并成一条附件清单(用户内容只进用户角色消息,项目红线)。
import (
"context"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
)
// batchMessage 是一条入站消息在本批里**已装配好、尚未落库**的产物。
type batchMessage struct {
prepared *preparedInbound
senderName string
attachments inboundContent
images inboundContent
voiceRef voiceref.Ref
document string
image string
msgs []model.Message
}
// runBatch 跑一批:**一个 turnID、一次 Run、一条助手回复**。批内 N 条消息各自落一条
// user 行(共享 turnID、顺序 = 到达顺序),助手回复与它们同 turnID。
//
// 它同时是「一条消息」的路径(batch 只有一个成员时逐字等于 M22 的 runInboundTurn):
// 主人私聊、以及 `process()` 的同步便捷路径都走这里。
func (b *Bridge) runBatch(ctx context.Context, batch []preparedInbound) {
if len(batch) == 0 {
return
}
last := &batch[len(batch)-1]
// 陌生人私聊的「同一发送者同时最多 1 个在跑回合」槽:一批 = 一个回合 = 一个槽。
if release := b.acquireStrangerTurn(last.item.msg, last.sess, last.item.a); release != nil {
defer release()
}
target := inboundTargetFor(last.sess, last.item.msg.Platform)
turnID := newTurnID()
runEnv := turnEnv(batch[0].sess, target,
b.resolvePersona(ctx, batch[0].sess, target, adapterName(batch[0].item.a)))
parts, msgs := b.buildBatchContext(ctx, batch, runEnv)
// 转录在落库**之前**读(M22 的既有保证):本批自己的行因此不会在上下文里出现两次。
msgs = b.seedTurnTranscript(ctx, target, msgs)
rows := b.persistBatchUsers(ctx, parts, target, turnID)
res, err := b.run.Run(loop.WithTurnID(loop.WithSessionEnv(ctx, runEnv), turnID), target.sessionID, msgs)
if err != nil {
b.log.Warn("agent run failed", "platform", last.item.msg.Platform,
"session", target.sessionID, "messages", len(batch), "error", err)
}
// 抽取结论(status/chars/truncated/reason)只能在回合跑起来之后才有,这里按
// 服务端存储名把结论补回刚落库的那一行(design/24 §6.1 的两段式)。
for _, row := range rows {
b.persistTurnDocumentStatus(row, res)
}
// 出错也交给 deliverReply:回合不能因为「模型那边出了问题」就对用户静默
// (空回复 / 轮次用尽 / 内部报错都落一条诚实的可见回复;被取消的回合除外)。
b.deliverReply(ctx, last.item.msg, last.item.a, last.sess, target, res, err, turnID)
}
// runBatchSegments 按 `solo` 把一批切成若干段:solo(系统通知升级成的真会话)**单独
// 一轮**,其余相邻消息合并成一轮。切分保持到达顺序,绝不为了多合并而把通知并进聊天。
//
// 罕见路径:solo 只在「传入前审查判为高价值事件并升级」时出现,通常一批里没有。
func (b *Bridge) runBatchSegments(ctx context.Context, kept []preparedInbound) {
start := 0
for i := range kept {
if !kept[i].solo {
continue
}
if i > start {
b.runBatch(ctx, kept[start:i])
}
b.runBatch(ctx, kept[i:i+1])
start = i + 1
}
if start < len(kept) {
b.runBatch(ctx, kept[start:])
}
}
// buildBatchContext 装配本批的模型可见消息:逐条摄取附件/语音/图片、构建用户消息、
// 只保留首条的系统框架行,并把每条消息的附属内容挂到**它自己**那条 user 行上。
func (b *Bridge) buildBatchContext(ctx context.Context, batch []preparedInbound, runEnv loop.SessionEnv) ([]batchMessage, []model.Message) {
parts := make([]batchMessage, 0, len(batch))
var msgs []model.Message
for i := range batch {
p := &batch[i]
msg := p.item.msg
// 入站附件摄取留在**闸门之后**(走到这里意味着这条已被放行):被黑白名单或
// 最小打扰拦下的消息绝不在盘上留下文件。
part := batchMessage{
prepared: p,
senderName: thirdPartySenderName(msg, p.item.actor),
attachments: b.ingestInboundDocuments(ctx, msg),
images: b.ingestInboundImages(msg),
voiceRef: b.ingestInboundVoiceCopy(ctx, msg, p.voiceIn),
}
part.msgs = b.buildLoopMessages(ctx, p.text, runEnv, p.item.actor, part.senderName, msg)
if i > 0 {
// 系统框架(来源行 + 第三方身份边界)每轮只出现一次:后续消息只贡献自己的
// user 行,否则上下文里会重复 N 遍同一段边界。
part.msgs = dropLeadingSystem(part.msgs)
}
attachInboundContent(part.msgs, part.attachments, part.images)
part.document = b.inboundDocumentColumn(msg, part.attachments)
part.image = inboundImageColumn(part.images)
parts = append(parts, part)
msgs = append(msgs, part.msgs...)
}
return parts, msgs
}
// dropLeadingSystem 去掉消息列表最前面的系统行(批内第 2..N 条用)。
func dropLeadingSystem(msgs []model.Message) []model.Message {
i := 0
for i < len(msgs) && msgs[i].Role == model.RoleSystem {
i++
}
return msgs[i:]
}
// persistBatchUsers 按**到达顺序**逐条落 user 行(共享 turnID),并把语音事实与用户
// 消息同源落库(design/24 §10.2 第 5 步)。返回刚落库的行,供回合结束后补抽取结论。
func (b *Bridge) persistBatchUsers(ctx context.Context, parts []batchMessage, target inboundTarget, turnID string) []history.Message {
rows := make([]history.Message, 0, len(parts))
for i := range parts {
p := &parts[i]
msg := p.prepared.item.msg
a := p.prepared.item.a
var row history.Message
if target.private {
row = b.persistToMain(ctx, history.RoleUser, p.prepared.text, "", p.document, p.image,
target.platform, deviceIDFor(msg), adapterName(a), turnID)
} else {
row = b.persistIsolated(ctx, history.RoleUser, p.prepared.text, p.document, p.image,
deviceIDFor(msg), p.prepared.sess, msg.Platform, p.senderName, adapterName(a), turnID)
}
b.persistMessageVoice(row, p.voiceRef)
rows = append(rows, row)
}
return rows
}
@@ -0,0 +1,266 @@
package channelbridge
// coalesce_continuation_test.go 是 M23 的**回合中续跑**(⑤)与两个边界(陈旧度叠加、
// 停机诚实)的断言集,对应蓝图 §12 的 #11/#12/#13/#14/#17。
//
// ⑤ 的实现要点(蓝图 §5):**不存在 "turn in flight" 标志** —— 车道的单消费者性质
// 就是那个容器。回合在跑时 worker 不在循环头,新消息落进同一个队列;回合结束回到
// 循环头,takeAll 把这段期间攒下的整片取出 ⇒ **一轮续跑**(不是 N 轮、也不是 0 条)。
import (
"context"
"fmt"
"log/slog"
"strings"
"testing"
"time"
)
// TestCoalesceMultiGroupIsolation(#11)断言作用域:两个群各 3 条交错到达 ⇒ 两批、
// 各自 3 条、互不串窗;同一平台的**两个账号**对同一个群则共用一条车道(与会话键同源,
// 是一个会话而不是两个 —— 见 lane.go 的 laneKey 取舍说明)。
func TestCoalesceMultiGroupIsolation(t *testing.T) {
t.Parallel()
chat := &laneChatter{}
f := newCoalesceBridge(t, Options{
Loop: chat,
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
for i := 0; i < 3; i++ {
f.send(coalesceGroupMsg("telegram", "-1", "9", "张三", fmt.Sprintf("a%d", i)))
f.send(coalesceGroupMsg("telegram", "-2", "9", "张三", fmt.Sprintf("b%d", i)))
}
f.pump(t, func() bool { return chat.runCount() >= 2 })
f.waitIdle(t)
if got := chat.runCount(); got != 2 {
t.Fatalf("turns started = %d, want 2 (one per group)", got)
}
seen := map[string]int{}
for i := 0; i < chat.runCount(); i++ {
texts := userTextsIn(chat.runAt(i).messages)
if len(texts) != 3 {
t.Fatalf("batch %d has %d messages, want 3 (%v)", i, len(texts), texts)
}
prefix := texts[0][:1]
for _, text := range texts {
if text[:1] != prefix {
t.Fatalf("batch %d mixes groups: %v", i, texts)
}
seen[prefix]++
}
}
if seen["a"] != 3 || seen["b"] != 3 {
t.Fatalf("messages per group = %v, want a:3 b:3", seen)
}
// 多账号:同一个群的两个账号(同 platform / 同 channel)是**同一个会话** ⇒ 一条车道、
// 一批;回复走最后一条消息自己的适配器(车道本身不持有账号级状态)。
other := &laneAdapter{name: "telegram:conf1"}
f.b.onMessage(context.Background(), coalesceGroupMsg("telegram", "-3", "9", "张三", "c0"), f.ad)
f.b.onMessage(context.Background(), coalesceGroupMsg("telegram", "-3", "10", "李四", "c1"), other)
before := chat.runCount()
f.pump(t, func() bool { return chat.runCount() > before })
f.waitIdle(t)
if got := chat.runCount(); got != before+1 {
t.Fatalf("turns started = %d, want %d (two accounts of one group = one lane, one batch)", got, before+1)
}
if got := userTextsIn(chat.runAt(before).messages); len(got) != 2 {
t.Fatalf("multi-account batch = %v, want both messages in one turn", got)
}
}
// TestContinuationAfterTurnEnds(#12)断言⑤:回合进行中到达 3 条 ⇒ 回合结束后**一轮**
// 续跑吃掉 3 条(不是 3 轮、不是 0 条)。
func TestContinuationAfterTurnEnds(t *testing.T) {
t.Parallel()
gate := make(chan struct{})
chat := &laneChatter{}
chat.onRun = func(user string) {
if user == "first" {
<-gate
}
}
f := newCoalesceBridge(t, Options{
Loop: chat,
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "first"))
f.timers.advanceAndFire(t, f.clock) // 第一批到点发车
chat.waitRuns(t, 1, 3*time.Second) // 第一轮在跑(卡住)
for i := 0; i < 3; i++ {
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", fmt.Sprintf("c%d", i)))
}
// 模拟「第一轮花了 100ms」:因此这 3 条的到达时刻**早于**上一轮结束时刻,
// 它们才是「回合中到达」的续跑(真实时钟下这一步自然成立)。
f.clock.advance(100 * time.Millisecond)
close(gate)
f.pump(t, func() bool { return chat.runCount() >= 2 })
f.waitIdle(t)
if got := chat.runCount(); got != 2 {
t.Fatalf("turns started = %d, want 2 (the first one + exactly one continuation)", got)
}
if got := userTextsIn(chat.runAt(1).messages); strings.Join(got, ",") != "c0,c1,c2" {
t.Fatalf("continuation batch = %v, want [c0 c1 c2]", got)
}
if stats := f.b.CoalesceStats(); stats.ContinuationsTotal != 1 {
t.Fatalf("continuations_total = %d, want 1", stats.ContinuationsTotal)
}
}
// TestContinuationNoDuplicateAndNoLoss(#13)断言⑤的「不重不丢」:每条消息的落库行恰好
// 1 条、每个 turn_id 恰好 1 条助手回复、`continuations_total==1`。
func TestContinuationNoDuplicateAndNoLoss(t *testing.T) {
t.Parallel()
gate := make(chan struct{})
chat := &laneChatter{}
chat.onRun = func(user string) {
if user == "first" {
<-gate
}
}
hist := newLaneHistory()
f := newCoalesceBridge(t, Options{
Loop: chat,
Sessions: coalesceGroupSessions{},
History: hist,
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "first"))
f.timers.advanceAndFire(t, f.clock) // 第一批到点发车
chat.waitRuns(t, 1, 3*time.Second)
for i := 0; i < 3; i++ {
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", fmt.Sprintf("c%d", i)))
}
f.clock.advance(100 * time.Millisecond)
close(gate)
f.pump(t, func() bool { return chat.runCount() >= 2 })
f.waitIdle(t)
userRows := map[string]int{}
assistantPerTurn := map[string]int{}
for _, row := range hist.snapshot("sess--20") {
switch row.Role {
case "user":
userRows[row.Content]++
case "assistant":
assistantPerTurn[row.TurnID]++
}
}
for _, text := range []string{"first", "c0", "c1", "c2"} {
if userRows[text] != 1 {
t.Fatalf("user row %q persisted %d times, want exactly 1", text, userRows[text])
}
}
if len(userRows) != 4 {
t.Fatalf("distinct user rows = %d, want 4: %v", len(userRows), userRows)
}
for turnID, n := range assistantPerTurn {
if n != 1 {
t.Fatalf("turn %s has %d assistant rows, want exactly 1", turnID, n)
}
}
if stats := f.b.CoalesceStats(); stats.ContinuationsTotal != 1 || stats.CoalescedDroppedTotal != 0 {
t.Fatalf("stats = %+v, want one continuation and zero coalesced drops", stats)
}
}
// TestCoalesceWithStaleGate(#14)断言与陈旧度闸的叠加:批内 2 条超龄 + 1 条新鲜 ⇒
// 只跑新鲜的 1 条,超龄的逐条留痕;整批都超龄 ⇒ 不发车 + all_stale 计数。
//
// 顺序要求(蓝图 §5):**先**陈旧度**后**组上下文,且绝不让「批里最新一条新鲜」把超龄
// 的旧条也塞进上下文(M22「太旧不补答」优先)。
func TestCoalesceWithStaleGate(t *testing.T) {
t.Parallel()
rec := &laneRecorder{}
chat := &laneChatter{}
f := newCoalesceBridge(t, Options{
Loop: chat,
LaneDepth: 16,
OnRejected: rec.record,
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "old1"))
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "old2"))
// 两条排着的时候时间越过陈旧度阈值,然后来了第三条新鲜的。
f.clock.advance(defaultLaneStaleAfter + time.Second)
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "fresh"))
// worker 可能已经把前面两条按「绝对上限已过」自行发车了(全超龄 ⇒ 不发车),
// 也可能把三条并成一批 —— 两种时序下都要把这一批推到发车,因此按窗口触发(而不是
// 假定它「自己会跑」,那是调度时序,不是被测语义)。
f.pump(t, func() bool { return chat.runCount() >= 1 })
f.waitIdle(t)
if got := chat.runCount(); got != 1 {
t.Fatalf("turns started = %d, want 1 (only the fresh message may run)", got)
}
if got := userTextsIn(chat.runAt(0).messages); strings.Join(got, ",") != "fresh" {
t.Fatalf("batch user messages = %v, want [fresh] only", got)
}
if stats := f.b.LaneStats(); stats.DroppedStale != 2 {
t.Fatalf("dropped_stale = %d, want 2 (each stale message traced individually)", stats.DroppedStale)
}
if reasons := rec.reasons(); len(reasons) != 2 {
t.Fatalf("audit reasons = %v, want two lane_stale entries", reasons)
}
if stats := f.b.CoalesceStats(); stats.CoalescedDroppedTotal != 0 {
t.Fatalf("coalesced_dropped_total = %d, want 0 (stale drops are M22's gate, not coalescing)", stats.CoalescedDroppedTotal)
}
// 整批都超龄:一条都不发车,但留一条可查的 all_stale 痕迹。
allStale := newCoalesceBridge(t, Options{
Loop: &laneChatter{},
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
allStale.send(coalesceGroupMsg("telegram", "-30", "9", "张三", "gone"))
// 时钟越过陈旧度阈值:循环头看见「绝对上限已过」⇒ 直接按 max_wait 发车,
// 逐条判完发现整批都超龄 ⇒ 一条都不跑(这一步根本不建定时器)。
allStale.clock.advance(defaultLaneStaleAfter + time.Second)
allStale.waitIdle(t)
if got := allStale.chat.runCount(); got != 0 {
t.Fatalf("turns started = %d for an all-stale batch, want 0", got)
}
if stats := allStale.b.CoalesceStats(); stats.BatchesAllStaleTotal != 1 {
t.Fatalf("batches_all_stale = %d, want 1 (stats=%+v)", stats.BatchesAllStaleTotal, stats)
}
}
// TestCoalesceStopsHonestly(#17)断言停机诚实:桥在窗口内停止时,那一批**不会静默
// 消失** —— 有带 source 的 WARN(count=N)、`abandoned_at_stop_total` 增加。
func TestCoalesceStopsHonestly(t *testing.T) {
t.Parallel()
logs := &safeBuffer{}
chat := &laneChatter{}
f := newCoalesceBridge(t, Options{
Loop: chat,
Log: slog.New(slog.NewTextHandler(logs, &slog.HandlerOptions{Level: slog.LevelDebug})),
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
// 走真实的生命周期:Start 建 b.ctx,车道 worker 与投递都用它,Stop 才取消得到它。
f.b.Start()
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m1"))
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m2"))
f.timers.take(t, 3*time.Second) // 确认它真的在等窗口(定时器已注册)
f.b.Stop(context.Background())
if got := chat.runCount(); got != 0 {
t.Fatalf("turns started = %d after stop, want 0", got)
}
if stats := f.b.CoalesceStats(); stats.AbandonedAtStopTotal != 2 {
t.Fatalf("abandoned_at_stop_total = %d, want 2", stats.AbandonedAtStopTotal)
}
out := logs.String()
if !strings.Contains(out, "coalesce batch abandoned at bridge stop") ||
!strings.Contains(out, "source=services/channelbridge/coalesce") ||
!strings.Contains(out, "count=2") {
t.Fatalf("stop left no honest trace in the log:\n%s", out)
}
}
@@ -0,0 +1,281 @@
package channelbridge
// coalesce_fixture_test.go 是 M23(静默窗口合并 + 回合中续跑)全部用例共用的夹具:
//
// - **注入时钟**(laneClock):窗口与陈旧度共用同一个时间源(Options.Now),
// 因此「2.0s 静默」「6s 绝对上限」「30s 超龄」都能被确定性推进;
// - **注入定时器工厂**(coalesceTimers):它既让窗口等待可以被手动触发,
// **又是「主人私聊从未建窗口」的证据面** —— calls 必须为 0;
// - **群消息夹具**:非主人、带提及(⇒ 必然进回合)、同一群共用一个隔离会话。
//
// 夹具全部落在 *_test.go 里,不进生产二进制。
import (
"context"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// coalesceOffFor 是「把静默窗口关掉」的注入面:lane_test.go 的 M22 语义用例用它,
// 使那些用例仍以毫秒级钉住车道本身(顺序/串行/背压/陈旧度)。
func coalesceOffFor(string) *runtimeconfig.CoalescePolicy {
p := runtimeconfig.DefaultCoalescePolicy()
p.Enabled = false
return &p
}
// coalesceOn 返回一组「群聊与陌生人私聊都开窗口」的生效参数。
func coalesceOn(windowMs, maxWaitMs, maxBatch int) runtimeconfig.CoalescePolicy {
p := runtimeconfig.DefaultCoalescePolicy()
p.Enabled = true
p.WindowMs = windowMs
p.MaxWaitMs = maxWaitMs
p.MaxBatchMessages = maxBatch
p.ApplyToStrangers = true
p.StrangerWindowMs = windowMs
return p
}
// coalesceFor 把一组固定参数包成注入面(同一实例可被多条车道共用)。
func coalesceFor(p runtimeconfig.CoalescePolicy) func(string) *runtimeconfig.CoalescePolicy {
return func(string) *runtimeconfig.CoalescePolicy {
c := p
return &c
}
}
// newCoalesceClock 返回一个**本地正午**的注入时钟。
//
// 起点刻意避开免打扰时段(23-07):本文件测的是窗口合并,而凌晨的注入时钟会让
// 非主人群消息先在「免打扰」那一档被静默掉 —— 那样每条用例都变成了空断言
// (测试要观察的只是「什么时候发车」,不是「说不说」)。
func newCoalesceClock() *laneClock {
c := &laneClock{}
c.t = time.Date(2026, 5, 1, 12, 0, 0, 0, time.Local)
return c
}
// coalesceTimers 是注入的定时器工厂:记录每次请求的时长,交回一个由测试手动触发的
// 通道(带缓冲,测试的发送永远不会阻塞)。
//
// 🔴 它同时是红线用例的证据面:主人私聊**一次都不该**调用它
// (TestOwnerPrivateNeverCreatesWindow 断言 calls==0)。
type coalesceTimers struct {
mu sync.Mutex
calls []time.Duration
pending []coalesceTimer
}
// coalesceTimer 是一次定时器请求:通道 + 当时请求的时长(测试按它推进注入时钟,
// 因此「到点」与「请求的时长」永远一致,不存在测试把时钟推多了的伪影)。
type coalesceTimer struct {
ch chan time.Time
d time.Duration
}
func (t *coalesceTimers) after(d time.Duration) <-chan time.Time {
ch := make(chan time.Time, 1)
t.mu.Lock()
t.calls = append(t.calls, d)
t.pending = append(t.pending, coalesceTimer{ch: ch, d: d})
t.mu.Unlock()
return ch
}
// callCount 返回定时器被请求的总次数。
func (t *coalesceTimers) callCount() int {
t.mu.Lock()
defer t.mu.Unlock()
return len(t.calls)
}
// durations 返回每次请求的时长(按请求顺序)。
func (t *coalesceTimers) durations() []time.Duration {
t.mu.Lock()
defer t.mu.Unlock()
return append([]time.Duration(nil), t.calls...)
}
// take 取走**最早一个尚未触发**的定时器(等它有请求为止)。
func (t *coalesceTimers) take(tb *testing.T, timeout time.Duration) coalesceTimer {
tb.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
t.mu.Lock()
if len(t.pending) > 0 {
timer := t.pending[0]
t.pending = t.pending[1:]
t.mu.Unlock()
return timer
}
t.mu.Unlock()
time.Sleep(time.Millisecond)
}
tb.Fatalf("no coalesce timer registered within %s (calls=%d)", timeout, t.callCount())
return coalesceTimer{}
}
// advanceAndFire 按**该定时器请求的时长**推进注入时钟,然后触发它
// (= 「静默窗口正好到点」)。
func (t *coalesceTimers) advanceAndFire(tb *testing.T, clock *laneClock) {
tb.Helper()
timer := t.take(tb, 3*time.Second)
clock.advance(timer.d)
timer.ch <- clock.Now()
}
// tryTake 非阻塞地取走最早一个尚未触发的定时器(没有就返回 false)。
func (t *coalesceTimers) tryTake() (coalesceTimer, bool) {
t.mu.Lock()
defer t.mu.Unlock()
if len(t.pending) == 0 {
return coalesceTimer{}, false
}
timer := t.pending[0]
t.pending = t.pending[1:]
return timer, true
}
// pump 反复「有定时器就按它请求的时长推进时钟并触发」,直到 done() 成立(或 3s 超时)。
//
// 为什么不是「按次数触发」:窗口到达的方式有两种 —— 静默到点(要定时器)与**绝对上限
// 已过**(循环头直接发车,**根本不建定时器**)。测试若假定「一定会有定时器」,
// 就会被调度时序(worker 什么时候启动)绊倒;pump 因此只看**结果条件**。
func (f *coalesceBridge) pump(t *testing.T, done func() bool) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if done() {
return
}
if timer, ok := f.timers.tryTake(); ok {
f.clock.advance(timer.d)
timer.ch <- f.clock.Now()
continue
}
time.Sleep(time.Millisecond)
}
t.Fatalf("condition never met within 3s (runs=%d, stats=%+v)", f.chat.runCount(), f.b.CoalesceStats())
}
// coalesceGroupSessions 给**每个群**一个稳定的隔离会话:同一群的多个成员 = 同一个会话
// (真实 SessionManager 就是这么派生的),因此「合成一轮」的落库行落在同一处。
type coalesceGroupSessions struct{}
func (coalesceGroupSessions) GetOrCreate(_ context.Context, msg adapter.UnifiedMessage, _ *user.OwnerProfile) (session.Session, error) {
return session.Session{
Platform: msg.Platform,
ChannelID: msg.ChannelID,
SessionID: "sess-" + msg.GroupID,
IsGroup: true,
MemWritable: false,
}, nil
}
// coalesceGroupMsg 是一条**非主人、指向她本人**的群消息:它因此必然进回合
// (不受最小打扰分级影响),是静默窗口最干净的夹具。
func coalesceGroupMsg(platform, groupID, sender, senderName, content string) adapter.UnifiedMessage {
return adapter.UnifiedMessage{
Platform: platform,
ChannelID: groupID,
ChannelType: adapter.ChannelGroup,
GroupID: groupID,
SenderID: sender,
SenderName: senderName,
Sender: adapter.Actor{Role: adapter.RoleMember},
Mentions: []adapter.Mention{{UserID: sender}},
Content: content,
ContentType: adapter.ContentText,
}
}
// coalesceBridge 是一个装好注入时钟与定时器工厂的桥接器(外加它的日志/历史/审计面)。
type coalesceBridge struct {
b *Bridge
chat *laneChatter
ad *laneAdapter
clock *laneClock
timers *coalesceTimers
}
// newCoalesceBridge 构造夹具:opts.Loop 必须是 *laneChatter(用例要读回合记录),
// 时钟与定时器工厂由夹具注入(opts.Now / opts.Coalesce.After 若已给出则不覆盖)。
func newCoalesceBridge(t *testing.T, opts Options) *coalesceBridge {
t.Helper()
chat, ok := opts.Loop.(*laneChatter)
if !ok {
t.Fatalf("newCoalesceBridge needs a *laneChatter loop, got %T", opts.Loop)
}
clock := newCoalesceClock()
timers := &coalesceTimers{}
if opts.Now == nil {
opts.Now = clock.Now
}
opts.Coalesce.After = timers.after
if opts.Sessions == nil {
opts.Sessions = coalesceGroupSessions{}
}
if opts.Owner == nil {
opts.Owner = &user.OwnerProfile{}
}
b, err := New(opts)
if err != nil {
t.Fatalf("New: %v", err)
}
ad := &laneAdapter{name: "telegram"}
b.Add(ad)
return &coalesceBridge{b: b, chat: chat, ad: ad, clock: clock, timers: timers}
}
// snapshot 在锁内返回某个会话已落库的行(测试直接读 h.rows 会与车道 worker 的
// 写入构成数据竞争 —— -race 下必红,因此统一走这里)。
func (h *laneHistory) snapshot(sessionID string) []history.Message {
h.mu.Lock()
defer h.mu.Unlock()
return append([]history.Message(nil), h.rows[sessionID]...)
}
// send 投递一条消息(走真实的 onMessage 入口:Validate → 流控闸 → 车道)。
//
// ctx 取 b.ctx(与生产的读循环一致:`readLoop` 传的就是桥自己的生命周期 context),
// 这样 Stop 取消的车道 worker 与用例投递的消息用的是同一棵树 —— 否则「停机诚实」
// 这条用例根本验证不到真实路径(b.ctx 为 nil 时退回 Background)。
func (f *coalesceBridge) send(msg adapter.UnifiedMessage) {
ctx := f.b.ctx
if ctx == nil {
ctx = context.Background()
}
f.b.onMessage(ctx, msg, f.ad)
}
// userTextsIn 返回一次回合的模型可见消息里的 user 文本(按顺序)。
func userTextsIn(msgs []model.Message) []string {
out := make([]string, 0, len(msgs))
for _, m := range msgs {
if m.Role != model.RoleUser {
continue
}
out = append(out, lastText(m))
}
return out
}
// waitRuns 等至少 n 个回合开跑(封装 chatter 的同名方法,省得到处传超时)。
func (f *coalesceBridge) waitRuns(t *testing.T, n int) {
t.Helper()
f.chat.waitRuns(t, n, 3*time.Second)
}
// waitIdle 等到车道全部退休。
func (f *coalesceBridge) waitIdle(t *testing.T) {
t.Helper()
waitLaneIdle(t, f.b, 2*time.Second)
}
@@ -0,0 +1,163 @@
package channelbridge
// coalesce_lane.go 把静默窗口接到车道上(M23 的「车道头部」):攒批 → 逐条陈旧度
// → 逐条闸门 → 单批上限拆分 → 一个 turnID 发车。它是 coalesce.go(窗口引擎)与
// coalesce_batch.go(一回合一 Reply)之间的那层「调度」。
//
// 本文件里**没有**任何「跳过某条消息」的分支,唯一的剔除来自 M22 的**既有**闸门:
// - 陈旧度闸(staleInbound:轮到时已超龄 ⇒ 不补答,逐条 WARN + 计数 + 审计);
// - 逐条闸门(prepareInbound:黑白名单 / 静默 / 空内容 / 仅登记的通知,全是
// 「这条本来就不该驱动回合」,不是「批装不下所以扔掉」)。
//
// 装不下的**不是丢弃**:超过单批上限的尾部原样回插队首,成为下一批(requeueFront),
// 并且 CoalesceStats().CoalescedDroppedTotal 恒为 0 —— 它存在的意义就是让「有人不小心
// 加了一条 continue」有一个能被断言的出口。
import (
"context"
)
// runBatchLaneItem 是 batch 车道的发车入口:把刚取到的 first 与此刻队列里的其余条目
// 合成一批,按窗口等待,然后交给 flushLaneBatch。
//
// 🔴 **回合中续跑(⑤)不需要额外标志**:worker 在跑回合时根本不在这里,新消息落进
// 同一个队列;回合结束回到 laneWorker 循环头,下一次 nextLaneItem + takeAll 就把
// 这段期间攒下的整片取出 ⇒ 一轮续跑(见 flushLaneBatch 的 continued 计数)。
func (b *Bridge) runBatchLaneItem(ctx context.Context, l *lane, first laneItem) {
w, windowed := b.coalesceWindowFor(l.key, laneStrangerPrivate(first))
if !windowed {
// 关窗(或该会话不适用窗口):行为**逐字回到 M22** —— 到达即投递、一条一轮。
// 刻意**不** takeAll:多取的条目会让「特性关闭」变成「悄悄合并」。
b.flushLaneBatch(ctx, l, []laneItem{first}, w, false, coalesceFlushOff)
return
}
items := append([]laneItem{first}, b.takeAll(l)...)
var flushReason string
items, flushReason = b.coalesceWait(ctx, l, items, w)
if flushReason == coalesceFlushStop {
b.abandonAtStop(l, items)
return
}
b.flushLaneBatch(ctx, l, items, w, true, flushReason)
}
// flushLaneBatch 把一批**已经攒好**的条目发车(或按上限拆开)。
//
// 顺序是刻意的:**拆分**(结构,不影响任何一条的去向)→ **取全局回合槽** →
// 逐条陈旧度 → 逐条闸门 → 组上下文 → 一次 Run。拆分放在最前面,因为回插的尾部
// 还没到「发车那一刻」,它的陈旧度要在它自己那一批发车时判。
func (b *Bridge) flushLaneBatch(ctx context.Context, l *lane, items []laneItem, w coalesceWindow, windowed bool, reason string) {
if len(items) == 0 {
return
}
items = b.splitBatch(l, items, w, windowed)
if !b.acquireTurn(ctx) {
// 桥停了:这一批一条都没跑,必须如实登记(绝不静默消失)。
b.abandonAtStop(l, items)
return
}
defer b.releaseTurn()
kept, stale := b.gateBatch(ctx, items)
if len(kept) == 0 {
if stale == len(items) {
// 整批都超龄:不发车、留一条可查的痕迹(M22 的「太旧不补答」优先)。
b.noteFlushReason(coalesceFlushAllStale)
b.coalesceLog.Info("coalesce batch all stale, nothing to say",
"lane", l.key, "count", len(items), "flush_reason", coalesceFlushAllStale)
l.lastTurnEnd = b.now()
return
}
// 一条都没进回合(全被逐条闸门静默/拒收):这是「回什么」的既有裁决,
// 不是合并丢的 —— 照样记一行,便于和上面的分支区分。
b.coalesceLog.Debug("coalesce batch produced no turn",
"lane", l.key, "count", len(items), "flush_reason", reason)
l.lastTurnEnd = b.now()
return
}
continued := !l.lastTurnEnd.IsZero() && items[0].arrivedAt.Before(l.lastTurnEnd)
b.countDeparture(kept, reason, continued)
b.logDeparture(l, items[0], kept, reason, continued)
b.runBatchSegments(ctx, kept)
l.lastTurnEnd = b.now()
}
// splitBatch 实施单批上限:超过上限时把**尾部**原样回插队首(保序、锁内、发车前),
// 本批只带走前 maxBatch 条。返回本批真正要发车的切片。
func (b *Bridge) splitBatch(l *lane, items []laneItem, w coalesceWindow, windowed bool) []laneItem {
if !windowed || w.maxBatch <= 0 || len(items) <= w.maxBatch {
return items
}
tail := items[w.maxBatch:]
b.requeueFront(l, tail)
b.coalesceStats.batchesSplit.Add(1)
b.coalesceLog.Info("coalesce batch split, tail requeued (not dropped)",
"lane", l.key, "kept", w.maxBatch, "requeued", len(tail), "max_batch", w.maxBatch)
return items[:w.maxBatch]
}
// gateBatch 是**逐条闸门**的批量形式,不是合并期的筛选:它调用的两个判断(陈旧度闸 /
// 逐条闸门)在 M22 的单条流水线里本来就对每一条各跑一次,合并**没有新增任何丢弃点** ——
// 因此 CoalescedDroppedTotal 恒为 0(见 coalesce.go 的说明)。
//
// 它返回要进本轮的消息与「因超龄被丢」的条数。陈旧度必须在**组上下文之前**判:不能让
// 「批里最新一条是新鲜的」把超龄的旧条也塞进上下文(M22「太旧不补答」优先);
// 被丢的每一条都由 dropInbound 留 WARN + 计数 + 审计(与单条路径逐字同一条流水)。
func (b *Bridge) gateBatch(ctx context.Context, items []laneItem) ([]preparedInbound, int) {
kept := make([]preparedInbound, 0, len(items))
stale := 0
for _, item := range items {
if b.staleInbound(item, b.now().Sub(item.arrivedAt)) {
stale++
continue
}
p, ok := b.prepareInbound(ctx, item)
if !ok {
continue
}
kept = append(kept, p)
}
return kept, stale
}
// countDeparture 记发车计数(批数/条数/发车原因/续跑)。计数与日志分开,
// 使「发车的形状」既能在日志里读、也能在测试里断言。
func (b *Bridge) countDeparture(kept []preparedInbound, reason string, continued bool) {
b.coalesceStats.batchesTotal.Add(1)
b.coalesceStats.batchMessagesTotal.Add(int64(len(kept)))
if continued {
b.coalesceStats.continuations.Add(1)
}
b.noteFlushReason(reason)
}
// logDeparture 记「这一批是怎么发车的」:条数、等了多久、发车原因、是否续跑。
// quiet/off 走 DEBUG,max_wait 走 INFO(它是「差点迟滞」的信号,值得在日常日志里看见)。
func (b *Bridge) logDeparture(l *lane, first laneItem, kept []preparedInbound, reason string, continued bool) {
attrs := []any{
"lane", l.key, "messages", len(kept),
"waited_ms", b.now().Sub(first.arrivedAt).Milliseconds(),
"flush_reason", reason, "continued", continued,
"platform", first.msg.Platform, "channel", first.msg.ChannelID, "group", first.msg.GroupID,
}
if reason == coalesceFlushMaxWait {
b.coalesceLog.Info("coalesce batch assembled (max wait reached)", attrs...)
return
}
b.coalesceLog.Debug("coalesce batch assembled", attrs...)
}
// abandonAtStop 诚实登记「进程停机时这批一条都没发车」。主人 2026-10-24 的口径是
// 「宁可晚回,不许不回」,但进程已经要停了 —— 唯一能做对的事是**说出来**:
// 带 source 的 WARN + 累计计数(abandoned_at_stop_total),绝不静默消失。
func (b *Bridge) abandonAtStop(l *lane, items []laneItem) {
if len(items) == 0 {
return
}
total := b.coalesceStats.abandonedAtStop.Add(int64(len(items)))
b.coalesceLog.Warn("coalesce batch abandoned at bridge stop",
"lane", l.key, "count", len(items),
"flush_reason", coalesceFlushStop, "abandoned_at_stop_total", total,
"platform", items[0].msg.Platform, "channel", items[0].msg.ChannelID)
}
@@ -0,0 +1,136 @@
package channelbridge
// coalesce_owner_test.go 是 M23 的**红线**断言集:主人私聊必须**结构性**地没有静默窗口。
//
// 断言的层次(塔 2026-10-24 要求「断言状态,不是断言结果」):
//
// ① `timers.callCount() == 0` —— 定时器工厂一次都没被调用(窗口**从未被创建**);
// ② `lane.batcher == nil` —— 那条车道的类型里根本没有窗口状态;
// ③ `CoalesceStats().WindowsCreated == 0` —— 计数面也证明没有窗口;
// ④ 消息立刻被回答(没有 2s 迟滞)。
//
// 变异检验(塔说会亲自做):把车道类型判定(ownerPrivateLane)改成「主人私聊也走
// batch 车道」⇒ ①②③ 立刻红。
import (
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// laneForTest 在锁内取一条车道(测试用;车道可能已退休 ⇒ nil)。
func laneForTest(b *Bridge, key string) *lane {
b.lanes.mu.Lock()
defer b.lanes.mu.Unlock()
return b.lanes.lanes[key]
}
// sent 返回该适配器已经送出去的回复(并发安全)。
func (a *laneAdapter) sent() []adapter.UnifiedMessage {
a.mu.Lock()
defer a.mu.Unlock()
return append([]adapter.UnifiedMessage(nil), a.sends...)
}
// ownerPrivateBridge 构造一个「主人私聊」夹具:主人白名单 telegram/100,会话层报
// Owner=true(与生产 SessionManager 的派生一致)。
func ownerPrivateBridge(t *testing.T, chat *laneChatter, p func(string) *runtimeconfig.CoalescePolicy) *coalesceBridge {
t.Helper()
return newCoalesceBridge(t, Options{
Loop: chat,
Sessions: &laneSessions{owner: true},
Owner: testOwnerProfile("telegram", "100"),
Coalesce: CoalesceOptions{For: p},
})
}
// assertNoTimer 在「等第一轮开跑」的同时盯住定时器工厂:一旦它被调用就立刻失败
// (变异体的**第一种红**正是这里:主人私聊被塞进 batch 车道 ⇒ 窗口被建出来)。
func assertNoTimer(t *testing.T, f *coalesceBridge, chat *laneChatter) {
t.Helper()
deadline := time.Now().Add(300 * time.Millisecond)
for time.Now().Before(deadline) {
if calls := f.timers.callCount(); calls != 0 {
t.Fatalf("coalesce timer factory called %d times for owner private traffic, want 0", calls)
}
if chat.runCount() > 0 {
return
}
time.Sleep(time.Millisecond)
}
}
// TestOwnerPrivateNeverCreatesWindow 断言红线:主人私聊**不建窗口、不建定时器**,
// 且消息立刻被回答。
func TestOwnerPrivateNeverCreatesWindow(t *testing.T) {
t.Parallel()
gate := make(chan struct{})
chat := &laneChatter{}
chat.onRun = func(string) { <-gate }
f := ownerPrivateBridge(t, chat, coalesceFor(coalesceOn(2000, 6000, 24)))
f.send(ownerMsg("主人说话"))
assertNoTimer(t, f, chat)
chat.waitRuns(t, 1, 3*time.Second)
// 车道还在(回合卡住)⇒ 这一刻的类型判定是可观察的。
if l := laneForTest(f.b, ownerPrivateLaneKey); l == nil {
t.Fatalf("owner private lane %q is missing while its turn is running", ownerPrivateLaneKey)
} else if l.batcher != nil {
t.Fatalf("owner private lane has a batch window (%+v): the type must be direct", l.batcher)
}
if calls := f.timers.callCount(); calls != 0 {
t.Fatalf("coalesce timer factory called %d times for owner private traffic, want 0", calls)
}
if stats := f.b.CoalesceStats(); stats.WindowsCreated != 0 {
t.Fatalf("windows_created = %d for owner private traffic, want 0", stats.WindowsCreated)
}
close(gate)
f.waitIdle(t)
if got := f.ad.sent(); len(got) != 1 {
t.Fatalf("replies = %d, want 1 (owner private must be answered immediately)", len(got))
}
}
// TestOwnerPrivateIgnoresEnabledConfig 断言「配置无权翻转车道类型」:把配置开到最激进
// (enabled / applyToStrangers 全开、窗口 10s),主人私聊仍然没有定时器、没有窗口;
// 而同一条配置对**群聊**是生效的(同一个定时器工厂会被调用)—— 后者是这条用例的
// **对照组**,用来证明 calls==0 不是「工厂没接上」这种伪结论。
func TestOwnerPrivateIgnoresEnabledConfig(t *testing.T) {
t.Parallel()
aggressive := coalesceOn(10000, 30000, 24)
aggressive.Enabled = true
aggressive.ApplyToStrangers = true
gate := make(chan struct{})
chat := &laneChatter{}
chat.onRun = func(string) { <-gate }
f := ownerPrivateBridge(t, chat, coalesceFor(aggressive))
f.send(ownerMsg("主人私聊"))
assertNoTimer(t, f, chat)
chat.waitRuns(t, 1, 3*time.Second)
if calls := f.timers.callCount(); calls != 0 {
t.Fatalf("timer factory called %d times with an aggressive config, want 0", calls)
}
if l := laneForTest(f.b, ownerPrivateLaneKey); l == nil || l.batcher != nil {
t.Fatalf("owner private lane = %+v, want a direct lane (batcher == nil)", l)
}
// 对照组:同一条配置对群聊**生效**(定时器被请求并按点到发车)。
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "群里的消息"))
f.timers.advanceAndFire(t, f.clock)
close(gate)
chat.waitRuns(t, 2, 3*time.Second)
f.waitIdle(t)
if calls := f.timers.callCount(); calls == 0 {
t.Fatal("control group: group traffic created no timer, so the fixture proves nothing")
}
if stats := f.b.CoalesceStats(); stats.WindowsCreated != 1 {
t.Fatalf("windows_created = %d, want 1 (only the group batch waited)", stats.WindowsCreated)
}
}
@@ -0,0 +1,151 @@
package channelbridge
// coalesce_prepare.go 把入站流水线拆成两段(M23 蓝图 §11 步 1):
//
// prepareInbound —— **逐条**闸门(语音归一 → 传入前审查/事件留存 → 空内容 →
// 黑白名单 → 会话解析 → 最小打扰/owner 车道 → 陌生人回合槽),
// 行为与 M22 的 processFrom 逐字一致,**一个字都没改**;
// runBatch —— **一批**一轮回合(见 coalesce_batch.go)。
//
// 为什么要拆:静默窗口只决定「什么时候把一批已经决定要说的东西说出来」,它不该
// 参与「说不说」的判断。拆开之后,闸门仍然逐条跑(每条消息照旧独立判静默/限流),
// 窗口只作用在闸门**之后**的调度上 —— 因此「批不得把一条本应静默的消息捞回来」
// 在结构上成立:被静默的消息在 prepareInbound 就 return false 了,根本进不了批。
import (
"context"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
)
// preparedInbound 是一条**已过全部逐条闸门**、确定要驱动回合的入站消息。
type preparedInbound struct {
// item 是车道条目(msg 已被语音归一/通知剥离就地改写过,actor 是本次解析结果)。
item laneItem
// sess 是解析出的会话绑定(主人私聊 → 主会话,群聊/陌生人 → 隔离会话)。
sess session.Session
// text 是本回合的正文(语音已归一成文本、通知已剥离)。
text string
// voiceIn 是那条语音的归一结论(只在放行之后才会落副本)。
voiceIn voiceTurn
// solo 为 true ⇒ 这条不能与别人并进同一批(传入前审查升级的真会话):
// 它单独一轮,以免把「平台事件升级」与「群友聊天」混成一次回答。
solo bool
}
// prepareInbound 逐条过闸门,返回「这条要不要驱动回合」以及驱动所需的一切。
//
// 它逐字保留了 M22 `processFrom` 的闸门顺序与日志,唯一的变化是**副作用分家**:
// 落库、装配上下文、跑回合、发回复都搬到了 runBatch;本函数只做判断与一次性 I/O
// (语音转写、事件留存)。
func (b *Bridge) prepareInbound(ctx context.Context, item laneItem) (preparedInbound, bool) {
msg := item.msg
// 语音 → 文本归一是入站流水线的**第一件事**(design/06 §1.1):它必须在「丢空判据」
// 之前发生,否则 telegram 的纯语音(无 caption ⇒ Content 为空)会被整条丢掉。
voiceIn := b.normalizeInboundVoice(ctx, &msg)
// design/12 §6 策略一补充:系统通知在进入任何下游之前先过「传入前审查」。
notice := b.preEntryReview(ctx, &msg, item.a)
// design/26 §4/§5:渠道事件留存的唯一插入点(无条件、早于所有早退分支、绝不决定回合走不走)。
b.recordEvent(ctx, msg, item.a, notice)
if notice.Applicable && !notice.Proceed {
b.registerNoticeOnly(ctx, msg, item.a, notice)
return preparedInbound{}, false
}
text := strings.TrimSpace(msg.Content)
// 无文本且无**任何可用媒体** ⇒ 没有可处理的内容(带附件的纯文件/纯语音/纯图片消息
// 必须继续往下走:附件就是它的内容,design/24 §4.4「不许静默丢」)。
if text == "" && !hasUsableMedia(msg) {
b.log.Debug("inbound message has no chat text, skipped",
"platform", msg.Platform, "channel", msg.ChannelID)
return preparedInbound{}, false
}
// design/16 §4.6 §6:黑白名单在流水线的最前端执行(早于会话解析与循环驱动)。
actor := session.ResolveActor(msg, b.owner)
if !actor.IsOwner() && b.blacklistDrop(msg, item.a) {
return preparedInbound{}, false
}
sess, err := b.sessions.GetOrCreate(ctx, msg, b.owner)
if err != nil {
b.log.Warn("session resolve failed", "platform", msg.Platform,
"channel", msg.ChannelID, "error", err)
return preparedInbound{}, false
}
b.log.Debug("inbound → session",
"platform", msg.Platform, "channel", msg.ChannelID,
"session", sess.SessionID, "actor_role", sess.ActorRole,
"env", sess.Env(), "mem_writable", sess.MemWritable)
// design/12 §6 策略一/二 + design/18 §11「乙 = C」:owner 车道不再无条件短路这道闸。
// 被拦下的消息只登记到其隔离会话日志并静默 —— 它**进不了批**(这就是「批不得把
// 本应静默的消息捞回来」的结构保证)。
//
// 由「传入前审查」放行的系统通知(notice.Applicable)不在此列:它们已由策略一终局判定。
if !notice.Applicable {
if ok, reason := b.shouldRespond(ctx, msg, actor, sess, item.a); !ok {
b.registerSilenced(ctx, msg, actor, sess, item.a)
b.log.Debug("inbound message silenced (minimal disturbance)",
"platform", msg.Platform, "channel", msg.ChannelID,
"session", sess.SessionID, "actor_role", actor.Role, "reason", reason)
return preparedInbound{}, false
}
}
return preparedInbound{
item: laneItem{msg: msg, a: item.a, actor: actor, arrivedAt: item.arrivedAt},
sess: sess,
text: text,
voiceIn: voiceIn,
solo: !coalesceAdmitsMessage(msg, notice),
}, true
}
// coalesceAdmitsMessage 报告这条入站是否允许进静默窗口(M23 蓝图 §11 第 4 条):
//
// exit 条件只有两类 —— **系统通知**(`notice.Applicable`,含升级成真会话的那些)与
// **非聊天内容类型**(action 帧:typing/join/leave/recall 等)。
//
// 理由不是省事:延迟 2s 只为合并它们毫无收益,还会污染留存时序;而「升级的真会话」
// 与「群友聊天」混成一次回答,等于让平台事件替群友说话。它们因此走 solo(单独一轮)。
func coalesceAdmitsMessage(msg adapter.UnifiedMessage, notice noticeOutcome) bool {
if notice.Applicable {
return false
}
switch msg.ContentType {
case adapter.ContentText, adapter.ContentImage, adapter.ContentVoice, adapter.ContentFile:
return true
default:
return false
}
}
// acquireStrangerTurn 施加「陌生人私聊同一发送者同时最多一个回合」的既有闸(裁决①)。
//
// 它由 runBatch 在**开跑前**取、**回合结束后**还 —— 也就是「一批 = 一个回合」占一个槽,
// 与 M22 的 `defer gate.ReleaseTurn(msg)` 同一语义(那一次 defer 从 processFrom 搬到了
// 回合本身)。放在这里而不是逐条闸门里是必须的:一批里同一发送者的第 2..N 条不该
// 被自己这一批占的槽挡下。
//
// M23 的车道已经给出**更强**的保证(同一会话串行),因此这道闸在车道里几乎不可能
// 拒绝;万一拒绝(闸门键与车道键分叉的缺陷),这里**绝不**再退回「这条不回」——
// 主人 2026-10-24 的口径是「宁可晚回,不许不回」,而本函数返回 nil 时消息仍会被本轮
// 回答,只是少了一个可以同时占用的槽。拒绝会留一条带 source 的 WARN,不会被吞掉。
func (b *Bridge) acquireStrangerTurn(msg adapter.UnifiedMessage, sess session.Session, a adapter.PlatformAdapter) func() {
if !isStrangerPrivate(sess) {
return nil
}
gate := b.gateFor(a)
if gate == nil {
return nil
}
if !gate.AcquireTurn(msg) {
b.log.Warn("stranger private turn slot busy inside a serialized lane, answering anyway",
"platform", msg.Platform, "channel", msg.ChannelID, "sender", msg.SenderID)
return nil
}
return func() { gate.ReleaseTurn(msg) }
}
@@ -0,0 +1,408 @@
package channelbridge
// coalesce_test.go 钉住 M23 的**静默窗口合并**(④)本身:滑动等待、到期发车、
// 绝对上限、零丢失/顺序/快照一致、开关回退、单批上限拆分。
//
// 对应 M23 蓝图 §12 的 #1/#2/#3/#4/#5/#6/#7/#8/#15/#16。
import (
"fmt"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// TestCoalesceWaitsForQuietThenFlushes(#1)断言**滑动**语义:第一条到达后 1.5s 又来一条,
// 计时重置;静默满 2.0s 才发车,且这一批同时带走两条。
//
// 若实现是 fixed 窗口(不重置),那么在 m1 到达 2.0s 时就会先发一次车(只剩 m1)——
// 下面「advance(1500) 之后一轮都不许跑」与「重置后再满 2.0s 才发一次、批内两条」
// 两点同时钉住它。
func TestCoalesceWaitsForQuietThenFlushes(t *testing.T) {
t.Parallel()
chat := &laneChatter{}
f := newCoalesceBridge(t, Options{
Loop: chat,
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m1"))
// 时钟走到 m1 之后 1.5s:窗口还没到点,一轮都不该跑(不是 fixed 窗口的 2.0s 边界)。
f.clock.advance(1500 * time.Millisecond)
if n := chat.runCount(); n != 0 {
t.Fatalf("turns started = %d after 1.5s, want 0 (window is 2.0s)", n)
}
// 窗口内的第二条:计时必须重置(新定时器 = 从 m2 起再等满一个窗口)。
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m2"))
f.timers.advanceAndFire(t, f.clock)
f.waitRuns(t, 1)
if n := chat.runCount(); n != 1 {
t.Fatalf("turns started = %d, want exactly 1 (two messages, one turn)", n)
}
got := userTextsIn(chat.runAt(0).messages)
if len(got) != 2 || got[0] != "m1" || got[1] != "m2" {
t.Fatalf("batch user messages = %v, want [m1 m2]", got)
}
if stats := f.b.CoalesceStats(); stats.FlushQuietTotal != 1 || stats.WindowsCreated != 1 {
t.Fatalf("stats = %+v, want one quiet flush on one window", stats)
}
}
// TestCoalesceFlushesOnWindowExpiry(#2)断言「只来一条」时窗口到期即发车:
// 到点**之前**一轮都不许跑(不提前),到点之后恰好一轮(不延后)。
func TestCoalesceFlushesOnWindowExpiry(t *testing.T) {
t.Parallel()
chat := &laneChatter{}
f := newCoalesceBridge(t, Options{
Loop: chat,
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "only"))
if n := chat.runCount(); n != 0 {
t.Fatalf("turns started = %d before the window expired, want 0", n)
}
f.timers.advanceAndFire(t, f.clock)
f.waitRuns(t, 1)
if n := chat.runCount(); n != 1 {
t.Fatalf("turns started = %d, want 1", n)
}
if got := userTextsIn(chat.runAt(0).messages); len(got) != 1 || got[0] != "only" {
t.Fatalf("batch user messages = %v, want [only]", got)
}
if stats := f.b.CoalesceStats(); stats.FlushMaxWaitTotal != 0 {
t.Fatalf("flush reason = max_wait, want quiet (stats=%+v)", stats)
}
}
// TestCoalesceTwelveBurstOneTurn(#3)是塔要的确定性判据:12 条 2s 内到达 ⇒
// **一次 Run**、模型可见 user 行 12 条、落库 12 user + 1 assistant、turn_id 全同。
func TestCoalesceTwelveBurstOneTurn(t *testing.T) {
t.Parallel()
chat := &laneChatter{}
hist := newLaneHistory()
f := newCoalesceBridge(t, Options{
Loop: chat,
Sessions: coalesceGroupSessions{},
History: hist,
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
const n = 12
for i := 0; i < n; i++ {
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", fmt.Sprintf("m%d", i)))
}
f.pump(t, func() bool { return chat.runCount() >= 1 })
if got := chat.runCount(); got != 1 {
t.Fatalf("turns started = %d, want exactly 1 for a 12-message burst", got)
}
if got := userTextsIn(chat.runAt(0).messages); len(got) != n {
t.Fatalf("batch user messages = %d, want %d: %v", len(got), n, got)
}
rows := hist.snapshot("sess--20")
userRows, assistantRows := 0, 0
turnIDs := map[string]struct{}{}
for _, row := range rows {
switch row.Role {
case "user":
userRows++
turnIDs[row.TurnID] = struct{}{}
case "assistant":
assistantRows++
}
}
if userRows != n || assistantRows != 1 {
t.Fatalf("persisted rows = %d user + %d assistant, want %d + 1", userRows, assistantRows, n)
}
if len(turnIDs) != 1 {
t.Fatalf("distinct user turnIDs = %d, want 1 (one batch = one turn)", len(turnIDs))
}
if stats := f.b.CoalesceStats(); stats.CoalescedDroppedTotal != 0 || stats.BatchMessagesTotal != n {
t.Fatalf("stats = %+v, want %d messages and zero coalesced drops", stats, n)
}
}
// TestCoalesceNeverDropsInWindow(#4)断言窗口期一条都不丢:20 条连发(超过默认车道深度
// 8,因此也顺带钉住「batch 车道的容量必须够装一整批」),最终 20 条**全部**按到达顺序
// 出现在某一批里,且没有任何合并期丢弃。
func TestCoalesceNeverDropsInWindow(t *testing.T) {
t.Parallel()
chat := &laneChatter{}
f := newCoalesceBridge(t, Options{
Loop: chat,
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
const n = 20
want := make([]string, 0, n)
for i := 0; i < n; i++ {
text := fmt.Sprintf("m%02d", i)
want = append(want, text)
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", text))
}
f.pump(t, func() bool { return chat.runCount() >= 1 })
var got []string
for i := 0; i < chat.runCount(); i++ {
got = append(got, userTextsIn(chat.runAt(i).messages)...)
}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("batch messages = %v, want %v (nothing dropped, order kept)", got, want)
}
stats := f.b.CoalesceStats()
if stats.CoalescedDroppedTotal != 0 {
t.Fatalf("coalesced_dropped_total = %d, want 0", stats.CoalescedDroppedTotal)
}
if lane := f.b.LaneStats(); lane.DroppedOverflow != 0 || lane.DroppedStale != 0 {
t.Fatalf("lane stats = %+v, want no drops (batch lane must fit a whole batch)", lane)
}
}
// TestCoalescePreservesArrivalOrder(#5)断言跨发送者的顺序:模型可见消息与落库行的顺序
// 都逐字等于到达顺序(两个群友交替说话),且每条仍是**独立的 user 消息**(不拼成一条)。
func TestCoalescePreservesArrivalOrder(t *testing.T) {
t.Parallel()
chat := &laneChatter{}
hist := newLaneHistory()
f := newCoalesceBridge(t, Options{
Loop: chat,
Sessions: coalesceGroupSessions{},
History: hist,
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
steps := []struct{ sender, name, text string }{
{"9", "张三", "m1"}, {"10", "李四", "m2"}, {"9", "张三", "m3"},
{"10", "李四", "m4"}, {"9", "张三", "m5"}, {"10", "李四", "m6"},
}
for _, s := range steps {
f.send(coalesceGroupMsg("telegram", "-20", s.sender, s.name, s.text))
}
f.pump(t, func() bool { return chat.runCount() >= 1 })
if got := chat.runCount(); got != 1 {
t.Fatalf("turns started = %d, want 1", got)
}
msgs := chat.runAt(0).messages
var users []model.Message
for _, m := range msgs {
if m.Role == model.RoleUser {
users = append(users, m)
}
}
if len(users) != len(steps) {
t.Fatalf("user messages = %d, want %d (one row per message, never concatenated)", len(users), len(steps))
}
for i, s := range steps {
if lastText(users[i]) != s.text {
t.Fatalf("user[%d] = %q, want %q (arrival order)", i, lastText(users[i]), s.text)
}
if users[i].SenderName != s.name {
t.Fatalf("user[%d] SenderName = %q, want %q", i, users[i].SenderName, s.name)
}
}
var persisted []string
for _, row := range hist.snapshot("sess--20") {
if row.Role == "user" {
persisted = append(persisted, row.Content)
}
}
if strings.Join(persisted, ",") != "m1,m2,m3,m4,m5,m6" {
t.Fatalf("persisted user rows = %v, want arrival order", persisted)
}
}
// TestCoalesceBatchesAreConsistentSnapshots(#6)断言批与批之间互不污染:
// 同一批里的消息**不会**出现在这一批自己的转录里(各自恰好一次),而前一批的行会作为
// 历史进入下一批 —— 这正是「播种在读之前、落库在后」的既有顺序。
func TestCoalesceBatchesAreConsistentSnapshots(t *testing.T) {
t.Parallel()
chat := &laneChatter{}
hist := newLaneHistory()
f := newCoalesceBridge(t, Options{
Loop: chat,
Sessions: coalesceGroupSessions{},
History: hist,
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "a1"))
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "a2"))
f.pump(t, func() bool { return chat.runCount() >= 1 })
f.waitIdle(t)
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "b1"))
f.pump(t, func() bool { return chat.runCount() >= 2 })
first := userTextsIn(chat.runAt(0).messages)
if strings.Join(first, ",") != "a1,a2" {
t.Fatalf("first batch user messages = %v, want [a1 a2] exactly once each", first)
}
second := userTextsIn(chat.runAt(1).messages)
if strings.Join(second, ",") != "a1,a2,b1" {
t.Fatalf("second batch user messages = %v, want [a1 a2 b1] (previous batch as transcript, own rows once)", second)
}
if joined := joinTexts(chat.runAt(1).messages); !strings.Contains(joined, "reply:a2") {
t.Fatalf("second batch is missing the previous assistant reply:\n%s", joined)
}
}
// TestCoalesceBatchUsesLatestContext(#7)断言最新性:续跑那一批的上下文里含上一轮刚落库的
// 助手回复(她接得上自己刚才说的话)。
func TestCoalesceBatchUsesLatestContext(t *testing.T) {
t.Parallel()
chat := &laneChatter{}
hist := newLaneHistory()
f := newCoalesceBridge(t, Options{
Loop: chat,
Sessions: coalesceGroupSessions{},
History: hist,
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "first"))
f.pump(t, func() bool { return chat.runCount() >= 1 })
f.waitIdle(t)
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "second"))
f.pump(t, func() bool { return chat.runCount() >= 2 })
if joined := joinTexts(chat.runAt(1).messages); !strings.Contains(joined, "reply:first") {
t.Fatalf("continuation context does not contain the previous reply:\n%s", joined)
}
}
// TestCoalesceMaxWaitForcesFlush(#8)断言绝对上限:每 500ms 来一条、连续 6s,
// 到 6s 必发车(滑动计时被一条条重置也压不住),绝不无限等下去。
func TestCoalesceMaxWaitForcesFlush(t *testing.T) {
t.Parallel()
chat := &laneChatter{}
f := newCoalesceBridge(t, Options{
Loop: chat,
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m1"))
for i := 1; i < 12; i++ {
f.clock.advance(500 * time.Millisecond)
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", fmt.Sprintf("m%d", i+1)))
}
// 最后那个定时器请求的正是「距离 6s 绝对上限还差多少」——按它推完最后 500ms,
// 循环头因此看见 maxLeft<=0 并按 max_wait 发车(滑动窗口被一条条重置也压不住它)。
f.timers.advanceAndFire(t, f.clock)
f.waitRuns(t, 1)
if n := chat.runCount(); n != 1 {
t.Fatalf("turns started = %d, want 1 (max wait forces one flush)", n)
}
if got := userTextsIn(chat.runAt(0).messages); len(got) != 12 {
t.Fatalf("batch user messages = %d, want 12", len(got))
}
if stats := f.b.CoalesceStats(); stats.FlushMaxWaitTotal != 1 {
t.Fatalf("stats = %+v, want flush_reason=max_wait", stats)
}
}
// TestCoalesceDisabledMatchesLaneBehavior(#15)断言开关回退:`enabled:false` ⇒ 12 条
// 就是 12 轮(到达即投递、一条一轮),**一个定时器都不建**,落库 12 user + 12 assistant。
//
// LaneDepth 取 16 是刻意的:关窗之后车道深度回到 M22 口径(默认 8),而本用例要断言
// 「逐条跑」而不是「背压丢了几条」,因此给它一条足够深的车道。
func TestCoalesceDisabledMatchesLaneBehavior(t *testing.T) {
t.Parallel()
chat := &laneChatter{}
hist := newLaneHistory()
f := newCoalesceBridge(t, Options{
Loop: chat,
Sessions: coalesceGroupSessions{},
History: hist,
LaneDepth: 16,
Coalesce: CoalesceOptions{For: coalesceOffFor},
})
const n = 12
for i := 0; i < n; i++ {
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", fmt.Sprintf("m%d", i)))
}
f.waitRuns(t, n)
f.waitIdle(t)
if got := chat.runCount(); got != n {
t.Fatalf("turns started = %d, want %d (feature off = one turn per message)", got, n)
}
if calls := f.timers.callCount(); calls != 0 {
t.Fatalf("coalesce timers created = %d, want 0 when the feature is off", calls)
}
stats := f.b.CoalesceStats()
if stats.WindowsCreated != 0 || stats.BatchesTotal != n {
t.Fatalf("stats = %+v, want 0 windows and %d batches", stats, n)
}
userRows, assistantRows := 0, 0
for _, row := range hist.snapshot("sess--20") {
switch row.Role {
case "user":
userRows++
case "assistant":
assistantRows++
}
}
if userRows != n || assistantRows != n {
t.Fatalf("persisted rows = %d user + %d assistant, want %d + %d", userRows, assistantRows, n, n)
}
}
// TestCoalesceBatchOverflowSplitsNotDrops(#16)断言单批上限:30 条(> 24)入队 ⇒
// 两批 24 + 6,顺序不变、零丢弃、被拆出去的尾部**排在最前**(成为下一批)。
func TestCoalesceBatchOverflowSplitsNotDrops(t *testing.T) {
t.Parallel()
chat := &laneChatter{}
f := newCoalesceBridge(t, Options{
Loop: chat,
LaneDepth: 40, // 让 30 条都排得下,从而让「拆分」这条分支可达
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
const n = 30
for i := 0; i < n; i++ {
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", fmt.Sprintf("m%02d", i)))
}
f.pump(t, func() bool { return chat.runCount() >= 1 })
// 被回插队尾的那 6 条**已经等过了**窗口(时钟在第一批到点时已走到窗口末尾),
// 因此它们下一批立刻发车,不需要再推进一个窗口。
f.waitRuns(t, 2)
if got := chat.runCount(); got != 2 {
t.Fatalf("turns started = %d, want 2 (24 + 6)", got)
}
first := userTextsIn(chat.runAt(0).messages)
second := userTextsIn(chat.runAt(1).messages)
if len(first) != 24 || len(second) != 6 {
t.Fatalf("batch sizes = %d + %d, want 24 + 6", len(first), len(second))
}
var all []string
all = append(all, first...)
all = append(all, second...)
for i, text := range all {
if text != fmt.Sprintf("m%02d", i) {
t.Fatalf("message[%d] = %q, want m%02d (order must survive the split)", i, text, i)
}
}
if stats := f.b.CoalesceStats(); stats.BatchesSplitTotal != 1 || stats.CoalescedDroppedTotal != 0 {
t.Fatalf("stats = %+v, want exactly one split and zero coalesced drops", stats)
}
}
// joinTexts 把一批模型可见消息的文本拼起来(断言「上下文里含有什么」用)。
func joinTexts(msgs []model.Message) string {
var out []string
for _, m := range msgs {
for _, c := range m.Content {
if c.Type == model.ContentTypeText {
out = append(out, c.Text)
}
}
}
return strings.Join(out, "\n")
}
+12 -195
View File
@@ -3,7 +3,6 @@ package channelbridge
import (
"context"
"errors"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
@@ -115,109 +114,20 @@ func (b *Bridge) process(ctx context.Context, msg adapter.UnifiedMessage) {
b.processFrom(ctx, msg, b.adapter(msg.Platform))
}
// processFrom 针对指定的账号适配器运行完整入站流水线:
// 解析会话/actor、应用允许/拒绝黑白名单、以聊天文本
// 驱动 agent 循环,并把循环的回复经接收该消息的同一
// 适配器送回去。身份由会话层结构化解析;
// 桥接器绝不读取消息内容来判定发送者是谁
// (项目红线)。
// processFrom 针对指定的账号适配器运行完整入站流水线:逐条过闸门(prepareInbound)
// 然后跑**一条**回合(runBatch)。身份由会话层结构化解析;桥接器绝不读取消息内容来
// 判定发送者是谁(项目红线)。
//
// 它是**同步**便捷路径(`process` 与测试用)。真实的渠道入站走车道
// (enqueueInbound → laneWorker → prepareInbound + runBatch),因为只有车道能提供
// 「同一会话串行」与「静默窗口合并」这两件事。
func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter) {
// 语音 → 文本归一是入站流水线的**第一件事**(切片 IM-ASR;design/06 §1.1):
// 它必须在「丢空判据」之前发生,否则 telegram 的纯语音(无 caption ⇒ Content
// 为空)会在下面被整条丢掉。归一写回 msg.Content 之后,最小打扰判定、落库与
// 模型可见消息看到的都是**同一份**文本(转写结果,或一行诚实的失败提示)。
//
// 它同时给出这条语音的**来源与三态结论**(切片 CODE-VOICE;design/24 §10.2 第 2 步),
// 由本函数的调用方一路带到闸门之后的落副本步与落库步 —— 结论与正文同源,绝不重算。
inboundVoice := b.normalizeInboundVoice(ctx, &msg)
// design/12 §6 策略一补充:系统通知在进入任何下游之前先过「传入前审查」——
// 归一化 → 剥离异常字段 → 分类 → fail-safe。被判为可忽略(或畸形/未识别)的
// 事件只登记、绝不驱动 loop;被放行的事件已就地换成**剥离后的安全文本**,
// 继续走常规流水线(限定注入 / 升级真会话)。
notice := b.preEntryReview(ctx, &msg, a)
// design/26 §4/§5(切片 E3):**渠道事件留存**的唯一插入点。
//
// 位置说明(与 §5.1 示意图的一行之差):preEntryReview **就是**那一步归一化
// (它调用适配器的 SystemNoticeNormalizer 并净化 SafeText),把 recordEvent 放在
// 它之后一行可以**只归一化一次**,并且 §9.2 第 2 条要求把 NoticeClass 的裁决结果
// 记进日志 —— 那只有这一行之后才有结论。关键性质不变:留存**无条件**,在任何
// 早退分支(仅登记 / 静默 / 升级)之前,且**绝不**决定 loop 走不走。
b.recordEvent(ctx, msg, a, notice)
if notice.Applicable && !notice.Proceed {
b.registerNoticeOnly(ctx, msg, a, notice)
item := laneItem{msg: msg, a: a, arrivedAt: b.now()}
p, ok := b.prepareInbound(ctx, item)
if !ok {
return
}
text := strings.TrimSpace(msg.Content)
// 无文本且无**任何可用媒体** ⇒ 没有可处理的内容。带附件的「纯文件消息」、带语音的
// 「纯语音消息」与带图片的「纯图片消息」(微信/QQ 发图常常没有配文)必须继续往下走:
// 附件/语音/图片就是它的内容(design/24 §4.4 的「不许静默丢」;语音那一路的归一已
// 保证它要么有文本、要么有诚实提示,图片那一路的失败也留一行中文诚实提示)。
if text == "" && !hasUsableMedia(msg) {
b.log.Debug("inbound message has no chat text, skipped",
"platform", msg.Platform, "channel", msg.ChannelID)
return
}
// design/16 §4.6 §6:黑白名单在流水线的最前端执行,
// 早于会话解析与循环驱动,因此被拉黑或不在白名单的
// 群/用户消息绝不会到达 LLM。
// 主人始终放行(由解析出的 actor 结构化决定)。
actor := session.ResolveActor(msg, b.owner)
if !actor.IsOwner() && b.blacklistDrop(msg, a) {
return
}
sess, err := b.sessions.GetOrCreate(ctx, msg, b.owner)
if err != nil {
b.log.Warn("session resolve failed", "platform", msg.Platform,
"channel", msg.ChannelID, "error", err)
return
}
b.log.Debug("inbound → session",
"platform", msg.Platform, "channel", msg.ChannelID,
"session", sess.SessionID, "actor_role", sess.ActorRole,
"env", sess.Env(), "mem_writable", sess.MemWritable)
// design/12 §6 策略一/二 + design/18 §11「乙 = C」(主人 2026-10-13 裁决):
// owner 车道不再**无条件短路**这道闸 —— 主人群消息也按结构化判据决定说不说。
// - 主人**私聊/非群**:恒回,不受影响;
// - 主人**群聊**:只有指向她本人的(@她 / @全体 / 未接线渠道旧判据)才必回,
// 其余**仍进上下文但不触发回复**(落进该群隔离会话,见 ownerlane.go);
// - 非主人:走既有的最小打扰分级(shouldRespond)。
// 被拦下的消息只登记到其隔离会话日志并静默——绝不驱动完整 agent 循环。
//
// 由「传入前审查」放行的系统通知(notice.Applicable)不在此列:它们已由策略一
// 的归一化/分类**终局判定**(高价值升级真会话、其余限定注入),若再走这道面向
// 聊天消息的闸门,actor=system 会在 isLowValueEvent 处被第二次静默掉。
if !notice.Applicable {
if ok, reason := b.shouldRespond(ctx, msg, actor, sess, a); !ok {
b.registerSilenced(ctx, msg, actor, sess, a)
b.log.Debug("inbound message silenced (minimal disturbance)",
"platform", msg.Platform, "channel", msg.ChannelID,
"session", sess.SessionID, "actor_role", actor.Role, "reason", reason)
return
}
}
// 裁决①的第二个流控(主人 2026-10-05):陌生人私聊虽然一律回答,但
// **同一发送者同时最多 1 个在跑的回合**。槽位复用同一个 flowgate 的
// 会话计数(AcquireTurn/ReleaseTurn,键口径与限流一致);已有回合在跑时
// 这一条只登记、不回,并留可见告警 —— 一个陌生人无法用并发消息占满循环。
if isStrangerPrivate(sess) {
if gate := b.gateFor(a); gate != nil {
if !gate.AcquireTurn(msg) {
b.registerThrottled(ctx, msg, actor, sess, a,
"stranger private: a turn is already running for this sender")
return
}
defer gate.ReleaseTurn(msg)
}
}
b.runInboundTurn(ctx, msg, a, text, actor, sess, inboundVoice)
b.runBatch(ctx, []preparedInbound{p})
}
// blacklistDrop 对非主人消息应用黑白名单,返回
@@ -260,99 +170,6 @@ func inboundTargetFor(sess session.Session, platform string) inboundTarget {
return inboundTarget{sessionID: sess.SessionID}
}
// runInboundTurn 为被放行的消息驱动 agent 轮次:解析
// 路由目标、把消息携带的文件类附件**落库并挂成文档部件**
// (design/24 §3;切片 M1)、把图片媒体内联成 data: URI 部件挂上既有视觉链路
// (切片 IM-IMG;design/24 §4.2 末)、把那条被归一过的语音的**可播副本**落盘并
// 产出 `voice` 引用(design/24 §10.2 第 4 步;切片 CODE-VOICE)、构建模型可见的
// 消息列表(再前置**该会话自己**最近的历史转录:主人私聊取主会话、群聊/陌生人取
// 该隔离会话,见 transcript.go),在轮次之前持久化入站
// 用户消息**连同它们的 `document` 引用列、`image` 列与 `voice` 列**(因此即使渠道
// 回复失败它也已落地,客户端也有附件芯片、图片与 🎤 可看),并在轮次之后把抽取结论
// 补回同一 document 列,运行循环并把结果交给 deliverReply。
//
// voiceTurn(切片 CODE-VOICE)是那条语音的归一结论 —— **只在放行之后**才可能落副本:
// 被黑白名单或最小打扰拦下的语音,盘上不留任何文件(与附件同一纪律)。
func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter,
text string, actor adapter.Actor, sess session.Session, voiceIn voiceTurn) {
target := inboundTargetFor(sess, msg.Platform)
// 入站附件摄取留在**闸门之后**(本函数只在消息被放行后调用):被黑白名单或
// 最小打扰拦下的消息绝不在盘上留下文件。成功者挂成文档部件,装配期
// resolveDocuments 照既有链路抽取/注入;读不到的走诚实提示。
attachments := b.ingestInboundDocuments(ctx, msg)
// 语音副本落盘与附件摄取**并列**、同处闸门之后:不可播的格式先转码(复用
// internal/capability/transcode 的既有能力),转码不可用就**不落副本**并如实记分类。
// 它拿到的字节是**原始字节**(转写早已在流水线最前端用同一份原始字节做完)。
voiceRef := b.ingestInboundVoiceCopy(ctx, msg, voiceIn)
// 图片内联与文档摄取**并列**、互不影响:文档走存储与抽取,图片走 data: URI 部件
// (同一条用户消息上两条都能挂)。它没有 ctx —— 这里没有任何 I/O(字节由适配器取回)。
images := b.ingestInboundImages(msg)
// 本回合的**权威 turn id**:IM 车道没有编排器,桥接器自己就是这条车道的回合
// 权威(turnid.go)。它在落库**之前**生成,因此同一次入站回合的用户消息、助手
// 回复带上的是同一个值,前端按 (sessionId, turnId) 两级分桶时才分得进同一个回合
// —— 这正是此前缺口所在:IM 的落库行没有 turn id,任何消费方都无法判定「这条
// 回复属于哪个回合」。它同时挂到 loop ctx 上,使循环发出的流帧与历史行同源。
turnID := newTurnID()
// 第三方边界只在受限(群聊/陌生人)上下文中生效:
// 记忆只读(MemWritable=false),并携带发送者名,
// 使 agent 能看到是谁在说话(design/16 §4.6 §1-2)。
// 谁算主人、谁算第三方由解析出的 actor 结构化决定——绝不
// 依据提示词文本。主人本人的发言**不带**发送者名(主人从不被标注):
// 受限会话里「带昵称的 user 消息 = 第三方」「不带昵称的 = 主人本人」
// 就是模型侧的归属判定车道(loop/attribution.go),落库也走同一个值 ——
// 否则转录会把主人的历史发言读成第三方人员说的。
//
// 该轮以哪个角色说话遵循 design/22 §2/§7-3/§7-4:主人私聊
// 镜像进主会话时取主会话的绑定,否则取账号的
// 渠道级绑定,再否则取全局默认;群聊或
// 陌生人会话强制使用默认角色。
runEnv := turnEnv(sess, target, b.resolvePersona(ctx, sess, target, adapterName(a)))
senderName := thirdPartySenderName(msg, actor)
loopMsgs := b.buildLoopMessages(ctx, text, runEnv, actor, senderName, msg)
// 附件/图片先挂到**本回合的**用户消息上(attachInboundContent 认列表里第一条
// RoleUser),再前置历史转录 —— 顺序反过来会把附件挂到转录里的旧用户消息上。
attachInboundContent(loopMsgs, attachments, images)
loopMsgs = b.seedTurnTranscript(ctx, target, loopMsgs)
// 只把部件挂在内存里的模型可见消息上,客户端读到的那条消息就是一片空白 —— 附件芯片
// 只认 `document` 列,图片只认 `image` 列(**图片绝不进 `document` 列**),🎤 只认
// `voice` 列。这里写的是引用与身份,文档结论要等回合跑完(下一段)。
document := b.inboundDocumentColumn(msg, attachments)
image := inboundImageColumn(images)
// 主人私聊消息落在 Web 主会话并广播给 Web 客户端;
// 受限消息落在其隔离会话(适配器日志页),
// 以 `channel_persisted` 事件广播(绝不进入
// 主/`web` 会话或 Web 聊天)。
// 无论有没有文本都要落库:附件/图片就是纯媒体消息的内容(design/24 §4.4)。
var userMsg history.Message
if target.private {
userMsg = b.persistToMain(ctx, history.RoleUser, text, "", document, image, target.platform, deviceIDFor(msg), adapterName(a), turnID)
} else {
userMsg = b.persistIsolated(ctx, history.RoleUser, text, document, image, deviceIDFor(msg), sess, msg.Platform, senderName, adapterName(a), turnID)
}
// 语音事实与用户消息**同源落库**(design/24 §10.2 第 5 步):写进刚落的那一行,
// 客户端因此能按结构化字段显示 🎤 与播放控件(绝不从正文里认媒体类型)。
b.persistMessageVoice(userMsg, voiceRef)
res, err := b.run.Run(loop.WithTurnID(loop.WithSessionEnv(ctx, runEnv), turnID), target.sessionID, loopMsgs)
if err != nil {
b.log.Warn("agent run failed", "platform", msg.Platform,
"session", target.sessionID, "error", err)
}
// 抽取结论(status/chars/truncated/reason)只能在回合跑起来之后才有,这里按
// 服务端存储名把结论补回刚落库的那一行(design/24 §6.1 的两段式,与 Web 侧
// gateway/session_document_status.go 同一口径)。
b.persistTurnDocumentStatus(userMsg, res)
// 出错也交给 deliverReply:回合不能因为「模型那边出了问题」就对用户静默
// (空回复 / 轮次用尽 / 内部报错都落一条诚实的可见回复;被取消的回合除外,
// 见 turnreply.go)。
b.deliverReply(ctx, msg, a, sess, target, res, err, turnID)
}
// deliverReply 解析本回合的可见回复、持久化并经接收该消息的适配器
// 送回去。可见回复由 turnVisibleReply 给出(turnreply.go):真实文本逐字,
// 空回复 / 轮次用尽 / 安全网终止 / 内部报错则是按原因区分的中文诚实说明 ——
@@ -360,7 +177,7 @@ func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage,
// (adapter.ErrUnsupported)只按 debug 级跳过,不算失败:回复
// 已在历史存储中落地。
//
// turnID 是这次入站回合的权威标识(见 runInboundTurn):助手回复与刚落的用户消息
// turnID 是这次入站回合的权威标识(见 coalesce_batch.go 的 runBatch):助手回复与刚落的用户消息
// 传同一个值,落库后两条行同属一路,客户端据此配对「回复 ↔ 提问」。
func (b *Bridge) deliverReply(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter,
sess session.Session, target inboundTarget, res *loop.Result, runErr error, turnID string) {
+241 -37
View File
@@ -98,11 +98,43 @@ type laneItem struct {
//
// neverDrop 只对**主人私聊**车道为真:它满了不拒收,条目改落 spill(溢出区,只排队)。
// spill 恒为空的车道因此不占额外内存。
//
// 车道分**两种类型**(M23 的红线级结构约束,见下面两个构造函数):
// - **direct 车道**(主人私聊):到达即投递、一条一条跑。它**没有** batcher 字段值,
// 因此拿不到定时器工厂 —— 「主人私聊没有静默窗口」是**类型上不可达**,
// 不是配置判出来的(配置无权翻转,见 newDirectLane)。
// - **batch 车道**(群聊 / 陌生人私聊):持有一个 batchWindow,由窗口引擎决定何时发车。
type lane struct {
key string
ch chan laneItem
neverDrop bool
spill []laneItem
// front 是「回插队首」区:单批超过上限时,尾部**原样保序**地插回这里,
// 成为下一批(M23 蓝图 §4-3:超量不是丢弃)。front 里的条目一定早于
// ch 与 spill 里的,因此读取顺序恒为 front → ch → spill。
front []laneItem
// batcher 非 nil ⇒ 这是一条 batch 车道(持有静默窗口的定时器工厂)。
// nil ⇒ direct 车道:窗口/定时器对它是**不可达类型**。
batcher *batchWindow
// lastTurnEnd 是该车道**上一轮回合结束**的时刻(只有 worker 读写 ⇒ 无需加锁)。
// 它只服务 continuations_total:批内首条在上一轮结束前就已到达 ⇒ 这批是⑤续跑。
lastTurnEnd time.Time
}
// newDirectLane 构造**主人私聊**的 direct 车道:它没有任何窗口状态,也不持有定时器
// 工厂(不传 batchWindow ⇒ 拿不到)。
//
// 🔴 车道类型由 **actor 元数据**(`IsOwner() && !IsGroupChat()`)决定,配置无权翻转:
// 唯一能选 direct 的地方是 enqueueInbound 的 ownerPrivateLane 判定。主人私聊是
// 「她和我说话」,2s 迟滞在私聊里没有任何收益(只有被拖慢的观感),
// 且 M22 已裁定主人私聊永不丢弃、不受陈旧度闸约束 —— 本函数是同一取向的延续。
func newDirectLane(key string, ch chan laneItem, neverDrop bool) *lane {
return &lane{key: key, ch: ch, neverDrop: neverDrop}
}
// newBatchLane 构造**群聊 / 陌生人私聊**的 batch 车道:窗口引擎只在这里可达。
func newBatchLane(key string, ch chan laneItem, neverDrop bool, batcher *batchWindow) *lane {
return &lane{key: key, ch: ch, neverDrop: neverDrop, batcher: batcher}
}
// laneSet 是全部活跃车道的注册表。车道在**空闲时退休**(worker 见队列空即把自己摘掉),
@@ -111,6 +143,9 @@ type laneSet struct {
depth int
staleAfter time.Duration
log *slog.Logger
// after 是静默窗口的定时器工厂(M23):**只**在构造 batch 车道时被塞进
// batchWindow,direct 车道拿不到它(见 newDirectLane)。
after func(time.Duration) <-chan time.Time
mu sync.Mutex
lanes map[string]*lane
@@ -118,14 +153,30 @@ type laneSet struct {
droppedOverflow int64
}
// newLaneSet 构造车道注册表;depth / staleAfter 为 0 时取上面的默认值。
func newLaneSet(depth int, staleAfter time.Duration, log *slog.Logger) *laneSet {
// laneSpec 描述「这条车道该长什么样」。类型(direct / batch)由**actor 元数据**决定,
// 调用方(enqueueInbound)算好传进来;配置只能影响窗口参数,改不了类型。
type laneSpec struct {
// neverDrop ⇒ 主人私聊:队列满时不拒收,落 spill 只排队。
neverDrop bool
// batch ⇒ 群聊/陌生人私聊:持有静默窗口(direct 车道不持有)。
batch bool
// capacity 是队列深度:batch 车道至少能装下一整批(否则「12 条成 1 轮」在
// 结构上不可达 —— 第 9 条就会在门前被拒收)。
capacity int
}
// newLaneSet 构造车道注册表;depth / staleAfter 为 0 时取上面的默认值,
// after 为 nil 时取 time.After。
func newLaneSet(depth int, staleAfter time.Duration, after func(time.Duration) <-chan time.Time, log *slog.Logger) *laneSet {
if depth <= 0 {
depth = defaultLaneDepth
}
if staleAfter <= 0 {
staleAfter = defaultLaneStaleAfter
}
if after == nil {
after = time.After
}
if log == nil {
log = logger.New(logger.Options{})
}
@@ -133,6 +184,7 @@ func newLaneSet(depth int, staleAfter time.Duration, log *slog.Logger) *laneSet
depth: depth,
staleAfter: staleAfter,
log: logger.WithSource(log, laneLogSource),
after: after,
lanes: make(map[string]*lane),
}
}
@@ -178,14 +230,13 @@ func (b *Bridge) LaneStats() LaneStats {
// enqueueInbound 把一条**已过流控闸**的入站消息投进它所属会话的车道,并立刻返回 ——
// 这是「读与回水解耦」的全部:适配器的读循环调用它之后马上可以继续读 socket。
//
// 🔴 这就是留给下一片(M23 静默窗口合并)的**入队 seam**:入参是一条**已过闸门的
// prepared item**(Validate/ResolveActor/Admit 都已在本函数的调用方 onMessage 里做完,
// 连同解析结果 actor 一起带进来)。车窗车道头部(laneWorker)只做「等待 + 发车」,
// 绝不在这里做准入判断 —— M23 要把 processFrom 的闸门段再往前抽成 prepareInbound 时,
// 只需在调用方一侧重排,车道本身不用动。
// 🔴 这就是 M23 静默窗口合并的**入队 seam**:入参是一条**已过闸门的 prepared item**
// (Validate/ResolveActor/Admit 都已在本函数的调用方 onMessage 里做完,连同解析结果
// actor 一起带进来)。车道头部只做「等(窗口)+ 发车」,准入判断不在这里。
//
// 三件事在这里一次性做完:
// - 认领车道(没有就建一条并起 worker);
// - 认领车道(没有就建一条并起 worker)—— **类型由 actor 元数据决定**:
// 主人私聊 ⇒ direct(无窗口),其余 ⇒ batch(有窗口);
// - 入队(群聊/陌生人满则**在门前拒收这一条**并留痕;主人私聊**永不拒收**);
// - 与 Stop 的 `wg.Wait` 对齐:`b.wg.Add` 与 `closed` 的判定在同一把 `b.mu` 下,
// 因此 Stop 要么看见这个工作项、要么我们看见 closed 而不入队,二者必居其一。
@@ -200,6 +251,7 @@ func (b *Bridge) enqueueInbound(ctx context.Context, msg adapter.UnifiedMessage,
// 主人私聊(私聊 + 主人在场,由 actor 元数据 + 白名单结构化判定)**永不丢弃**:
// 队列满时落到溢出区只排队(主人 2026-10-24 裁定:「宁可晚回,不许不回」)。
neverDrop := ownerPrivateLane(msg, actor)
spec := b.laneSpecFor(key, item, neverDrop)
b.mu.Lock()
if b.closed {
@@ -208,7 +260,7 @@ func (b *Bridge) enqueueInbound(ctx context.Context, msg adapter.UnifiedMessage,
"platform", msg.Platform, "channel", msg.ChannelID)
return
}
l, start, rejected := b.lanes.offer(key, item, neverDrop)
l, start, rejected := b.lanes.offer(key, item, spec)
if start {
b.wg.Add(1)
}
@@ -222,6 +274,24 @@ func (b *Bridge) enqueueInbound(ctx context.Context, msg adapter.UnifiedMessage,
}
}
// laneSpecFor 决定一条车道的形状:**类型只由 actor 元数据决定**
// (`IsOwner() && !IsGroupChat()` ⇒ direct),窗口参数只影响容量。
//
// 容量:batch 车道至少要能装下一整批,否则「一批 N 条」在结构上不可达
// (队列深度 8 时第 9 条就会在门前被拒收,合并就成了摆设)。因此
// capacity = max(默认深度, 生效的单批上限);direct 车道用默认深度。
// 内存口径因此是「活跃会话数 × 批上限」,而批上限本身被 clamp 在 [1,50]。
func (b *Bridge) laneSpecFor(key string, item laneItem, neverDrop bool) laneSpec {
spec := laneSpec{neverDrop: neverDrop, batch: !neverDrop, capacity: b.lanes.depth}
if neverDrop {
return spec
}
if w, ok := b.coalesceWindowFor(key, laneStrangerPrivate(item)); ok && w.maxBatch > spec.capacity {
spec.capacity = w.maxBatch
}
return spec
}
// offer 在**一个临界区**内完成「查/建车道 + 入队」,使 `retireLaneIfIdle` 不可能夹在
// 中间把刚查到、还没写入的车道摘掉(那样条目会落进一条没有 worker 的死车道)。
//
@@ -230,19 +300,31 @@ func (b *Bridge) enqueueInbound(ctx context.Context, msg adapter.UnifiedMessage,
// (ownerLaneBacklogWarn 及其翻倍)留 WARN —— 一个白名单账号的积压只反映「她落后了」,
// 不构成第三方洪泛面;
// - **群聊 / 陌生人**:**在门前拒收这一条**(而不是挤掉队列里已有的某条)。队列里
// 的条目因此一条都不会凭空消失 —— 下一片 M23 的 takeAll 依赖这一点;被拒收的那条
// 的条目因此一条都不会凭空消失 —— 窗口的 takeAll 依赖这一点;被拒收的那条
// 由调用方留痕(WARN + 计数 + 审计)。
//
// 🔴 **FIFO 的关键**:`spill` 一旦非空,后面的条目**一律继续追加到 spill**,不再回写
// `ch`。否则「ch 腾出一个位置 + 此时正好有新条目到达」会让后到者超车(复审实测顺序
// `[m1 m2 m3 m5 m4]`)。`nextLaneItem` 因此可以安全地「先取完 ch、再按序取 spill」:
// spill 里的一定晚于 ch 里的,而 spill 一旦开始就用到底(直到它被取空)。
func (s *laneSet) offer(key string, item laneItem, neverDrop bool) (l *lane, start bool, rejected bool) {
//
// 同理,`front`(回插队首区)里的一定**早于** ch 与 spill 里的(只有 worker 自己
// 在发车前把超量的尾部放进去),所以读取顺序恒为 front → ch → spill。
func (s *laneSet) offer(key string, item laneItem, spec laneSpec) (l *lane, start bool, rejected bool) {
s.mu.Lock()
defer s.mu.Unlock()
l = s.lanes[key]
if l == nil {
l = &lane{key: key, ch: make(chan laneItem, s.depth), neverDrop: neverDrop}
capacity := spec.capacity
if capacity <= 0 {
capacity = s.depth
}
ch := make(chan laneItem, capacity)
if spec.batch {
l = newBatchLane(key, ch, spec.neverDrop, &batchWindow{after: s.after})
} else {
l = newDirectLane(key, ch, spec.neverDrop)
}
s.lanes[key] = l
start = true
}
@@ -284,9 +366,18 @@ func ownerPrivateLane(msg adapter.UnifiedMessage, actor adapter.Actor) bool {
return !msg.IsGroup() && actor.IsOwner()
}
// nextLaneItem 按 FIFO 取下一条:先把缓冲区取完,再取主人私聊的溢出区(溢出区里的条目
// 一定晚于缓冲区里的,所以顺序仍然是到达顺序)。ok=false 表示此刻真的没有待办。
// nextLaneItem 按 FIFO 取下一条:先取回插队首区(front),再把缓冲区取完,最后取
// 主人私聊的溢出区(三者的先后由 offer / requeueFront 的写入口径保证)。
// ok=false 表示此刻真的没有待办。
func (b *Bridge) nextLaneItem(l *lane) (laneItem, bool) {
b.lanes.mu.Lock()
if len(l.front) > 0 {
item := l.front[0]
l.front = l.front[1:]
b.lanes.mu.Unlock()
return item, true
}
b.lanes.mu.Unlock()
select {
case item := <-l.ch:
return item, true
@@ -303,13 +394,68 @@ func (b *Bridge) nextLaneItem(l *lane) (laneItem, bool) {
return item, true
}
// takeAll 把车道上**此刻**排着的条目整片取出(顺序 = 到达顺序),供静默窗口的
// 批组装使用。它是合并阶段**唯一**的取队列入口(M23 蓝图 §4-1):
//
// - 只有车道的单消费者(该车道唯一的 worker)调用它 ⇒ 不存在两个消费者各抓一半;
// - 取出即「这批就是这些」,等待期间新到的继续落进队列,下一轮 takeAll 再取;
// - 它**不**做任何筛选:不丢、不重排、不改内容(窗口内零丢失的结构保证)。
func (b *Bridge) takeAll(l *lane) []laneItem {
b.lanes.mu.Lock()
out := l.front
l.front = nil
b.lanes.mu.Unlock()
for {
select {
case item := <-l.ch:
out = append(out, item)
continue
default:
}
b.lanes.mu.Lock()
if len(l.spill) > 0 {
out = append(out, l.spill...)
l.spill = nil
}
b.lanes.mu.Unlock()
return out
}
}
// requeueFront 把一批**尚未发车**的条目按原顺序插回队首,成为下一批。
//
// 它是「单批上限不是丢弃」的落点(M23 蓝图 §4-3):超量的**尾部**原样回插、保序、
// 在锁内、在发车之前。回插到 front 而不是写 ch:ch 有界且写满即拒收,
// 而 front 无界但只由 worker 在发车前写入,写入量与「刚拆出来的尾部」相等。
func (b *Bridge) requeueFront(l *lane, items []laneItem) {
if len(items) == 0 {
return
}
b.lanes.mu.Lock()
merged := make([]laneItem, 0, len(items)+len(l.front))
merged = append(merged, items...)
merged = append(merged, l.front...)
l.front = merged
b.lanes.mu.Unlock()
}
// laneWorker 是某会话车道的唯一执行者:串行跑完每一条,队列空了就把车道交还注册表
// (空闲车道不占内存,也不会把「这个人再说话」变慢)。
//
// **回合中续跑(⑤)就落在这里**:不存在任何 "turn in flight" 标志 —— 「回合在跑」
// 在结构上就等于「worker 正忙」,跑回合期间新消息落进同一个队列,回合结束回到循环头,
// takeAll 自然把这段期间攒下的消息整片取出 ⇒ **一轮续跑**(不是 N 轮,也不是 0 条)。
func (b *Bridge) laneWorker(ctx context.Context, l *lane) {
defer b.wg.Done()
for {
// 桥已停:队列里还没发车的条目**如实登记**(绝不静默消失),然后退休。
if ctx.Err() != nil {
b.abandonLaneAtStop(l)
b.retireLane(l)
return
}
if item, ok := b.nextLaneItem(l); ok {
b.runLaneItem(ctx, item)
b.dispatchLaneItem(ctx, l, item)
continue
}
// 队列空了才谈退休:摘表与入队用同一把锁,因此「摘下」与「刚有人塞进来」不会
@@ -323,39 +469,60 @@ func (b *Bridge) laneWorker(ctx context.Context, l *lane) {
b.retireLane(l)
return
case item := <-l.ch:
b.runLaneItem(ctx, item)
b.dispatchLaneItem(ctx, l, item)
}
}
}
// runLaneItem 跑车道上的一条:先取全局回合槽(**在线程池里等,不在读循环里等**),
// 再过陈旧度闸,最后交给原样的入站流水线。
// dispatchLaneItem 按**车道类型**分派:direct(主人私聊)一条一条跑;batch 走窗口
// 引擎(见 coalesce_lane.go 的 runBatchLaneItem)。
//
// 陈旧度在**取到槽之后**才判:排队等槽的那段时间同样是「让它等」,必须计入年龄,
// 否则一道繁忙的全局上限会把「等了很久」洗成「刚到」。
func (b *Bridge) runLaneItem(ctx context.Context, item laneItem) {
select {
case b.sem <- struct{}{}:
case <-ctx.Done():
// 分派依据是 `l.batcher == nil` —— 一个**结构**判据,不是配置开关:direct 车道的
// 类型里根本没有 batcher 字段值,窗口代码对它不可达。
func (b *Bridge) dispatchLaneItem(ctx context.Context, l *lane, item laneItem) {
if l.batcher == nil {
b.runDirectLaneItem(ctx, l, item)
return
}
defer func() { <-b.sem }()
b.runBatchLaneItem(ctx, l, item)
}
// runDirectLaneItem 跑 direct 车道(= 主人私聊)的一条:先取全局回合槽(**在线程池里
// 等,不在读循环里等**),再过陈旧度闸,然后走逐条闸门 + 单条回合。**没有任何等待窗口**。
func (b *Bridge) runDirectLaneItem(ctx context.Context, l *lane, item laneItem) {
if !b.acquireTurn(ctx) {
return
}
defer b.releaseTurn()
waited := b.now().Sub(item.arrivedAt)
if b.staleInbound(item, waited) {
return
}
// 「这条等了多久才开跑」是「她为什么回得晚」在生产日志里最直接的那个答案;
// 与它同时出现在日志里的 `msg="inbound frame"`(适配器的入站帧 DEBUG)则说明
// 读循环在等的同时一直在读 —— 两行合起来就是「回合没有堵住心跳」的证据。
b.lanes.log.Debug("lane turn started",
"waited", waited.String(),
"platform", item.msg.Platform, "channel", item.msg.ChannelID,
"sender", item.msg.SenderID, "group", item.msg.GroupID)
// 不变量 ④:上下文在这里(出队之后)才装配,因此一轮看到的是最新历史。
b.processFrom(ctx, item.msg, item.a)
b.logLaneStart(item, waited, 1)
p, ok := b.prepareInbound(ctx, item)
if !ok {
l.lastTurnEnd = b.now()
return
}
b.runBatch(ctx, []preparedInbound{p})
l.lastTurnEnd = b.now()
}
// acquireTurn 取一个全局回合槽(maxConcurrent 个)。ctx 取消时返回 false:桥停了
// 就不再开新回合(carried 条目由 Stop 的诚实登记负责,见 coalesce_lane.go)。
func (b *Bridge) acquireTurn(ctx context.Context) bool {
select {
case b.sem <- struct{}{}:
return true
case <-ctx.Done():
return false
}
}
// releaseTurn 归还一个全局回合槽。
func (b *Bridge) releaseTurn() { <-b.sem }
// staleInbound 实现陈旧度闸:轮到时已超龄 ⇒ 丢弃不补答,并留 WARN + 计数 + 审计。
// 返回 true 表示这条已被丢弃。waited 由调用方算好(含等全局回合槽的时间)。
func (b *Bridge) staleInbound(item laneItem, waited time.Duration) bool {
@@ -422,18 +589,55 @@ func (s *laneSet) noteDrop(reason string) (stale, overflow int64) {
// retireLaneIfIdle 在车道已空时把它从注册表摘掉并报告 true(worker 随即退出)。
//
// 它必须与 `offer` 用同一把锁:两者交错的话会出现「offer 拿到车道 → worker 退休并删除 →
// offer 往一条没有 worker 的车道入队」的丢失。入队与「查空」在同一把锁下,就不存在这个窗口。
// 它必须与 `offer` / `requeueFront` 用同一把锁:与 offer 交错的话会出现「offer 拿到
// 车道 → worker 退休并删除 → offer 往一条没有 worker 的车道入队」的丢失。入队与
// 「查空」在同一把锁下,就不存在这个窗口。
func (b *Bridge) retireLaneIfIdle(l *lane) bool {
b.lanes.mu.Lock()
defer b.lanes.mu.Unlock()
if len(l.ch) > 0 || len(l.spill) > 0 || b.lanes.lanes[l.key] != l {
if len(l.front) > 0 || len(l.ch) > 0 || len(l.spill) > 0 || b.lanes.lanes[l.key] != l {
return false
}
delete(b.lanes.lanes, l.key)
return true
}
// abandonLaneAtStop 登记「桥停止时车道里还排着、因此永远不会被回答的条目」。
//
// 它与 coalesce_lane.go 的 abandonAtStop 分工明确:那边管**已经取在手上、正在等窗口**
// 的那一批,这里管**还躺在队列里**的(front/ch/spill)。两处都带 source 落 WARN 并计入
// abandoned_at_stop_total —— 进程要停了没法「晚回」,唯一能做对的是**说出来**。
func (b *Bridge) abandonLaneAtStop(l *lane) {
b.lanes.mu.Lock()
remaining := len(l.front) + len(l.ch) + len(l.spill)
b.lanes.mu.Unlock()
if remaining == 0 {
return
}
total := b.coalesceStats.abandonedAtStop.Add(int64(remaining))
b.lanes.log.Warn("lane queue abandoned at bridge stop",
"lane", l.key, "count", remaining, "abandoned_at_stop_total", total)
}
// logLaneStart 记「这一批等了多久才开始跑」—— 「她为什么回得晚」在生产日志里最直接
// 的那个答案。与它同时出现在日志里的 `msg="inbound frame"`(适配器的入站帧 DEBUG)
// 则说明读循环在等的同时一直在读:两行合起来就是「回合没有堵住心跳」的证据。
func (b *Bridge) logLaneStart(item laneItem, waited time.Duration, messages int) {
b.lanes.log.Debug("lane turn started",
"waited", waited.String(), "messages", messages,
"platform", item.msg.Platform, "channel", item.msg.ChannelID,
"sender", item.msg.SenderID, "group", item.msg.GroupID)
}
// laneStrangerPrivate 报告一条入站是否属于**陌生人私聊**(非群 + 非主人)。
//
// 它与 `isStrangerPrivate(sess)` 同源(同一判据的两面):会话层从 actor 派生
// `sess.Owner`,这里直接用 actor 元数据,因此不需要先查会话就能判定「这条车道
// 要不要受 applyToStrangers 管」。主人私聊走不到这里(那是 direct 车道)。
func laneStrangerPrivate(item laneItem) bool {
return !item.msg.IsGroup() && !item.actor.IsOwner()
}
// retireLane 无条件把车道从注册表摘掉(桥停止时的清理;未处理的条目随取消作废)。
func (b *Bridge) retireLane(l *lane) {
b.lanes.mu.Lock()
@@ -264,8 +264,17 @@ func ownerMsg(content string) adapter.UnifiedMessage {
return m
}
// newLaneBridge 构造一个**只跑 M22 车道语义**的桥接器:本文件(lane_test.go)钉的是
// 车道本身(顺序/串行/背压/陈旧度),因此默认把 M23 的静默窗口**关掉** ——
// 开着窗口时「2s 静默」会让这几条用例从毫秒级变成秒级,并且把「陈旧度何时判」的
// 时间口径搅在一起。窗口/合并/续跑自己的断言都在 coalesce_test.go。
//
// 需要窗口的用例显式传 Options.Coalesce(见 newCoalesceBridge)。
func newLaneBridge(t *testing.T, opts Options, ad *laneAdapter) *Bridge {
t.Helper()
if opts.Coalesce.For == nil {
opts.Coalesce.For = coalesceOffFor
}
b, err := New(opts)
if err != nil {
t.Fatalf("New: %v", err)
@@ -141,10 +141,17 @@ func TestStrangerPrivateFlowControlPerSender(t *testing.T) {
}
}
// TestStrangerPrivateSecondTurnRegisteredWhileOneRuns 钉住裁决③的后半句:
// 「同一发送者同时最多 1 个在跑的回合」。先用 AcquireTurn 占住该发送者的槽位(模拟
// 一个回合正在跑),再让下一条消息过门 —— 它必须只登记、不回,并留可见告警。
func TestStrangerPrivateSecondTurnRegisteredWhileOneRuns(t *testing.T) {
// TestStrangerPrivateSecondTurnAnsweredNotThrottled 钉住 M23 对裁决③后半句的**改写**:
// 「同一发送者同时最多 1 个在跑的回合」这道闸仍然保留(runBatch 开跑前取、回合结束后还),
// 但「已经有回合在跑 ⇒ 这条只登记、不回」已经被 **排队**取代 —— 塔 2026-10-24 的裁定原文:
// 「陌生人私聊 `AcquireTurn` 那段由 M23 步 3 改(把「已有回合 ⇒ 不回」改成排队)」。
//
// 结构上「排队」由车道提供(同一会话一条车道、单消费者 ⇒ 不可能有两轮同时跑),
// 因此能走到「槽被占」的只剩并发路径(例如本用例直接调 process 而另一条路正占着槽)。
// 这时**绝不静默**:留一条带 source 的 WARN,消息照答(宁可晚回,不许不回)。
//
// 变异检验:把 runBatch 里的那个 WARN 分支改回 registerThrottled + return ⇒ 本用例必红。
func TestStrangerPrivateSecondTurnAnsweredNotThrottled(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "嗯"}}
@@ -161,16 +168,16 @@ func TestStrangerPrivateSecondTurnRegisteredWhileOneRuns(t *testing.T) {
t.Cleanup(func() { gate.ReleaseTurn(msg) })
b.process(context.Background(), msg)
if chat.sessionID != "" {
t.Fatalf("已有回合在跑时仍开了第二个回合:session=%q", chat.sessionID)
if chat.sessionID == "" {
t.Fatal("回合槽被占时消息没有回答:M23 的口径是排队回答,不是不回")
}
if got := len(hist.records()); got != 1 {
t.Fatalf("被并发拦下的消息登记 %d 条,want 1", got)
if got := len(hist.records()); got != 2 {
t.Fatalf("登记 %d 条,want 2(user + assistant:这条被回答了)", got)
}
logged := logBuf.String()
if !strings.Contains(logged, "throttled by flow gate") ||
if !strings.Contains(logged, "answering anyway") ||
!strings.Contains(logged, "source=services/channelbridge") {
t.Fatalf("并发拦下没有留下带 source 的可见日志:%q", logged)
t.Fatalf("槽被占照答没有留下带 source 的可见日志:%q", logged)
}
}
+249
View File
@@ -0,0 +1,249 @@
package gateway
// 本文件承载**渠道静默窗口合并**(M23)的管理读写端点:
//
// GET /api/v1/admin/channels/coalesce 读:盘上的窗口参数 + 取值规则 + 只读事实
// PUT /api/v1/admin/channels/coalesce 写:三态补丁 + 原子落盘 + 热生效 + 审计
//
// 它回答的是主人的原话「要让群聊聊天很自然,很流畅的做法」:群里「一鼓作气打三句」
// 不该让她回三次 —— 但「等多久 / 最多攒多少」这类参数只该在面板上调,
// 不该为改一个 2.0s → 3.0s 去 SSH 改文件(与 memory-tidy 调优同一取向)。
//
// 四条口径(每条都在 admin_coalesce_test.go 里钉住):
//
// 1. **三态**:键**缺位** ⇒ 不动;**合法值** ⇒ 采用;**非法值** ⇒ `400 invalid_payload`
// 且**盘上一个字节都不动**(校验全部发生在落盘之前,见
// runtimeconfig.ValidateCoalescePatch)。
// 2. **写端点带审计**(动作 `channels.coalesce`),`writeAuth`(owner|admin)——
// 与 `admin_memory_tuning` 同档:它写的是**面板配置文件**,不是身份/凭据面,
// 因此不是 owner-only;也不给 viewer(它是渠道调优,不是监控投影)。
// 3. **热生效**:桥每次发车前从**同一个** store 实例读生效值,因此保存后下一次发车
// 就用新值 —— 响应里的 `hot:true` 是事实,不是承诺。
// 4. **主人私聊是只读事实**:车道类型由 actor 元数据决定(`IsOwner() && !IsGroupChat()`
// ⇒ direct 车道、**没有窗口对象、拿不到定时器工厂**),本端点**没有任何键**能翻转它。
// `ownerPrivate:"never"` 就是这条事实的下发形状,面板据此只显示一行说明、不给开关。
//
// 零新 `.env` 键(design/13 §2.1):配置落在 `$DATA_DIR/config/coalesce.json`。
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
const (
// coalesceLogSource 是本子功能的日志来源(模块/子功能可追溯,AGENTS §10)。
coalesceLogSource = "gateway/channels-coalesce"
// coalesceAuditAction 是写端点落审计的动作名(与 channels.* 同族命名)。
coalesceAuditAction = "channels.coalesce"
// coalesceMaxBodyBytes 是写端点请求体的上限:这是一份给人改的小配置
// (6 个标量键 + 少量会话覆盖),正常请求体不到 1 KiB。
coalesceMaxBodyBytes = 32 << 10
// coalesceOwnerPrivateFact 是「主人私聊不受此设置影响」的**机器可读**形式。
// 它不是开关值:没有任何请求体字段能把它改成别的(改车道类型要改 actor 判定)。
coalesceOwnerPrivateFact = "never"
)
// CoalesceConfigStore 是「渠道静默窗口配置」的可选能力:生产装配的 `s.channels`
// 就是 cmd/cyrene 的 `*runtimeChannels`,它背后持有**与 bridge 同一个**
// `runtimeconfig.CoalesceStore`(因此写进内存即热生效)。
//
// 用可选接口而不是往 Deps/Server 加字段:渠道调优本来就在这个依赖面上,
// 加字段会让「谁持有什么」多一条没人复得到的路径(与 gateway 其它可选能力同一手法)。
type CoalesceConfigStore interface {
// CoalescePath 返回配置文件落点(面板如实回显改的是哪个文件)。
CoalescePath() string
// CoalescePresent 报告配置文件是否已存在(false = 还没写过,运行期正走代码默认)。
CoalescePresent() bool
// CoalesceSnapshot 返回盘上那份原值(未 clamp,供面板回显「你写进去的是什么」)。
CoalesceSnapshot() runtimeconfig.CoalesceFile
// ApplyCoalesce 应用三态补丁并原子落盘。返回是否真的改动了盘上的字节。
ApplyCoalesce(patch runtimeconfig.CoalescePatch) (bool, error)
}
// AdminCoalescePolicy 是一组窗口参数(生效值)。
type AdminCoalescePolicy struct {
Enabled bool `json:"enabled"`
WindowMs int `json:"windowMs"`
MaxWaitMs int `json:"maxWaitMs"`
MaxBatchMessages int `json:"maxBatchMessages"`
ApplyToStrangers bool `json:"applyToStrangers"`
StrangerWindowMs int `json:"strangerWindowMs"`
}
// AdminCoalesceRules 是取值规则(随读下发 ⇒ 面板表单与后端校验不可能两套规则)。
type AdminCoalesceRules struct {
// WindowMs 是允许区间(毫秒);越界会被后端 clamp 并留 WARN,而不是拒绝。
WindowMs [2]int `json:"windowMs"`
// MaxWaitMs 的下界**跟随窗口**(因此 [0] 是窗口下界、[1] 是绝对上限)。
MaxWaitMs [2]int `json:"maxWaitMs"`
// MaxBatchMessages 是单批条数区间(超出不是丢弃,是拆成下一批)。
MaxBatchMessages [2]int `json:"maxBatchMessages"`
// Clamp 说明越界值的处置("clamp"),使面板不必猜。
Clamp string `json:"clamp"`
// FlushReasons 是发车原因的闭集(面板/文档据此解释日志里的 flush_reason)。
FlushReasons []string `json:"flushReasons"`
}
// AdminCoalesceView 是 GET / PUT `/admin/channels/coalesce` 的 data。
type AdminCoalesceView struct {
// Path 是面板配置文件的落点(与 channels.json 同一棵配置树)。
Path string `json:"path"`
// Present 报告那份文件是否已存在(false = 还没写过,运行期正走代码默认)。
Present bool `json:"present"`
// Hot 报告这份配置是否**热生效**(桥每次发车前重读同一 store ⇒ true)。
Hot bool `json:"hot"`
// Default 是**按代码默认 + 盘上 default 段**归一后的生效值(面板的「当前值」)。
Default AdminCoalescePolicy `json:"default"`
// Patch 是盘上 default 段的**原值**:键缺位 ⇒ 不出现(= 跟随代码默认)。
// 有了它,面板的「留空 ⇒ 跟随默认 2000ms」才是照实回显而不是猜。
Patch map[string]any `json:"patch"`
// Conversations 是每会话覆盖的**生效值**(键 = `platform|channel`,可读形态)。
Conversations map[string]AdminCoalescePolicy `json:"conversations"`
// Rules 是取值规则;OwnerPrivate 是那条只读事实(永远 "never")。
Rules AdminCoalesceRules `json:"rules"`
OwnerPrivate string `json:"ownerPrivate"`
}
// adminCoalesceRead 是 GET 的处理函数:只读、不审计(与 /admin/config-facts 同档)。
func (s *Server) adminCoalesceRead(w http.ResponseWriter, _ *http.Request) {
store, ok := s.coalesceStore()
if !ok {
writeError(w, apperr.Wrap(apperr.ErrInternal, "coalesce_unavailable",
"渠道静默窗口配置面未接线:面板配置文件落点无法确定"))
return
}
writeJSON(w, http.StatusOK, buildCoalesceView(store))
}
// adminCoalesceWrite 是 PUT 的处理函数:解析 → 校验 → 落盘 → 回显真值 → 审计。
//
// 顺序是刻意的(口径 1):**任何**校验失败都在落盘之前返回 400,
// 因此「非法值 ⇒ 盘上一个字节都不动」是结构性的,不是靠回滚。
func (s *Server) adminCoalesceWrite(w http.ResponseWriter, r *http.Request) {
store, ok := s.coalesceStore()
if !ok {
s.auditClaims(r, coalesceAuditAction, "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "coalesce_unavailable",
"渠道静默窗口配置面未接线:面板配置文件落点无法确定"))
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, coalesceMaxBodyBytes))
if err != nil {
s.auditClaims(r, coalesceAuditAction, "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "读取请求体失败"))
return
}
patch, err := parseCoalescePatch(body)
if err != nil {
s.auditClaims(r, coalesceAuditAction, "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "invalid_payload", "%v", err))
return
}
wrote := false
if !patch.Empty() {
wrote, err = store.ApplyCoalesce(patch)
if err != nil {
s.auditClaims(r, coalesceAuditAction, "error")
logger.WithSource(s.log, coalesceLogSource).Error(
"coalesce config write failed", "path", store.CoalescePath(), "error", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "coalesce_write_failed",
"面板配置文件落盘失败(盘上内容未改动):%v", err))
return
}
}
log := logger.WithSource(s.log, coalesceLogSource)
if wrote {
log.Info("coalesce config saved (hot: the bridge rereads it before every departure)",
"path", store.CoalescePath(), "action", coalesceAuditAction)
} else {
log.Info("coalesce config unchanged (no bytes written)", "path", store.CoalescePath())
}
s.auditClaims(r, coalesceAuditAction, "ok")
writeJSON(w, http.StatusOK, buildCoalesceView(store))
}
// coalesceStore 解析「渠道静默窗口配置」的可选能力面。
func (s *Server) coalesceStore() (CoalesceConfigStore, bool) {
if s.channels == nil {
return nil, false
}
store, ok := s.channels.(CoalesceConfigStore)
if !ok || store.CoalescePath() == "" {
return nil, false
}
return store, true
}
// parseCoalescePatch 解析 PUT 请求体:严格解码(未知键即错),并做「敲错级」校验
// (负数)。越界的合法数字不在这里拒绝 —— 它们由运行期 clamp 并留 WARN。
func parseCoalescePatch(body []byte) (runtimeconfig.CoalescePatch, error) {
var patch runtimeconfig.CoalescePatch
dec := json.NewDecoder(bytes.NewReader(body))
dec.DisallowUnknownFields()
if err := dec.Decode(&patch); err != nil {
return runtimeconfig.CoalescePatch{}, fmt.Errorf("请求体不是合法的窗口参数补丁:%w", err)
}
if dec.More() {
return runtimeconfig.CoalescePatch{}, fmt.Errorf("请求体只允许一个 JSON 对象")
}
if patch.Default != nil {
if err := runtimeconfig.ValidateCoalescePatch(*patch.Default); err != nil {
return runtimeconfig.CoalescePatch{}, err
}
}
for key, conv := range patch.Conversations {
if conv == nil {
continue
}
if err := runtimeconfig.ValidateCoalescePatch(*conv); err != nil {
return runtimeconfig.CoalescePatch{}, fmt.Errorf("%s:%w", key, err)
}
}
return patch, nil
}
// buildCoalesceView 组装面板视图:盘上原值 + 生效值 + 取值规则 + 只读事实。
func buildCoalesceView(store CoalesceConfigStore) AdminCoalesceView {
file := store.CoalesceSnapshot()
view := AdminCoalesceView{
Path: store.CoalescePath(),
Present: store.CoalescePresent(),
Hot: true,
Default: adminCoalescePolicy(file.EffectiveDefault()),
Patch: file.Default.Values(),
Conversations: map[string]AdminCoalescePolicy{},
Rules: AdminCoalesceRules{
WindowMs: [2]int{runtimeconfig.CoalesceWindowMinMs, runtimeconfig.CoalesceWindowMaxMs},
MaxWaitMs: [2]int{runtimeconfig.CoalesceWindowMinMs, runtimeconfig.CoalesceMaxWaitMaxMs},
MaxBatchMessages: [2]int{runtimeconfig.CoalesceBatchMin, runtimeconfig.CoalesceBatchMax},
Clamp: "clamp",
FlushReasons: []string{"quiet", "max_wait", "all_stale", "off", "stop"},
},
OwnerPrivate: coalesceOwnerPrivateFact,
}
for key := range file.Conversations {
if policy, ok := file.EffectiveConversation(key); ok {
view.Conversations[key] = adminCoalescePolicy(policy)
}
}
return view
}
// adminCoalescePolicy 把 runtimeconfig 的策略投影成面板形状(字段一一对应)。
func adminCoalescePolicy(p runtimeconfig.CoalescePolicy) AdminCoalescePolicy {
return AdminCoalescePolicy{
Enabled: p.Enabled,
WindowMs: p.WindowMs,
MaxWaitMs: p.MaxWaitMs,
MaxBatchMessages: p.MaxBatchMessages,
ApplyToStrangers: p.ApplyToStrangers,
StrangerWindowMs: p.StrangerWindowMs,
}
}
@@ -0,0 +1,217 @@
package gateway
// 本文件锁定渠道静默窗口合并(M23)的读写端点
// (`GET|PUT /api/v1/admin/channels/coalesce`):
//
// ① 读侧如实回显:生效值(代码默认 + 盘上 default 段)+ 盘上原值 patch(缺位键不出现)
// + 取值规则 + 「主人私聊永远不受影响」这条只读事实 + hot:true;
// ② 写侧**三态**:键缺位 ⇒ 逐字节不动;合法值 ⇒ 采用且保留同文件其它键与其它会话;
// 非法值(负数/未知键/类型错) ⇒ `400 invalid_payload` 且**盘上一个字节都不动**;
// ③ 热生效是事实(同一 store 实例被桥每次发车前重读),响应 `hot:true`;
// ④ RBAC 走 writeAuth(owner|admin),写落审计 `channels.coalesce`;
// ⑤ 请求体里**没有任何**键能翻转「主人私聊不走窗口」——那由 actor 元数据决定。
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// coalesceEnv 是一个把 coalesce 配置文件放进临时数据目录的网关测试环境。
type coalesceEnv struct {
url string
path string
jwt *auth.JWTService
store *runtimeconfig.CoalesceStore
}
// coalesceStubChannels 是 ChannelConfigStore + CoalesceConfigStore 的生产形状替身:
// 它把 coalesce 的读写直接委托给一个真的 runtimeconfig.CoalesceStore(因此落盘、
// 三态、热生效都是真实路径,而不是再写一套假的)。
type coalesceStubChannels struct {
store *runtimeconfig.CoalesceStore
}
func (c *coalesceStubChannels) Path() string { return "" }
func (c *coalesceStubChannels) Load() map[string][]config.ChannelAccount { return nil }
func (c *coalesceStubChannels) Reload(string) error { return nil }
func (c *coalesceStubChannels) CoalescePath() string { return c.store.Path() }
func (c *coalesceStubChannels) CoalescePresent() bool { return c.store.Present() }
func (c *coalesceStubChannels) CoalesceSnapshot() runtimeconfig.CoalesceFile {
return c.store.Snapshot()
}
func (c *coalesceStubChannels) ApplyCoalesce(p runtimeconfig.CoalescePatch) (bool, error) {
return c.store.Apply(p)
}
// newCoalesceEnv 构造环境:coalesce.json 落在 t.TempDir()/config/coalesce.json。
func newCoalesceEnv(t *testing.T) *coalesceEnv {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "config", "coalesce.json")
store := runtimeconfig.NewCoalesceStore(path, nil)
deps := Deps{DataDir: dir, Channels: &coalesceStubChannels{store: store}}
_, ts, jwt := buildEnv(t, cfgDefault(), deps)
return &coalesceEnv{
url: ts.URL + "/api/v1/admin/channels/coalesce",
path: path,
jwt: jwt,
store: store,
}
}
// disk 读回盘上的原始字节(「非法值不落盘」的证据就是它与之前相等 / 文件仍不存在)。
func (e *coalesceEnv) disk(t *testing.T) ([]byte, bool) {
t.Helper()
raw, err := os.ReadFile(e.path)
if err != nil {
if os.IsNotExist(err) {
return nil, false
}
t.Fatalf("read coalesce file: %v", err)
}
return raw, true
}
// view 解一次成功响应的 data。
func (e *coalesceEnv) view(t *testing.T, body []byte) AdminCoalesceView {
t.Helper()
var view AdminCoalesceView
if err := json.Unmarshal(decodeBody(t, body)["data"], &view); err != nil {
t.Fatalf("decode view: %v (body=%s)", err, body)
}
return view
}
func TestAdminCoalesceReadShowsDefaultsAndRules(t *testing.T) {
t.Parallel()
e := newCoalesceEnv(t)
token := signAny(t, e.jwt, "acct-owner", auth.RoleOwner)
resp, body := doReq(t, http.MethodGet, e.url, nil, token)
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
view := e.view(t, body)
if !view.Hot || view.Present {
t.Fatalf("view = %+v, want hot:true and present:false for a fresh deployment", view)
}
if view.OwnerPrivate != "never" {
t.Fatalf("ownerPrivate = %q, want the read-only fact 'never'", view.OwnerPrivate)
}
// 缺位 ⇒ 生效值是代码默认(群聊窗口 2.0s / 绝对上限 6s / 单批 24)。
if view.Default.WindowMs != 2000 || view.Default.MaxWaitMs != 6000 || view.Default.MaxBatchMessages != 24 {
t.Fatalf("default policy = %+v, want 2000/6000/24", view.Default)
}
if !view.Default.Enabled || !view.Default.ApplyToStrangers {
t.Fatalf("default policy = %+v, want both switches on by default", view.Default)
}
if len(view.Patch) != 0 {
t.Fatalf("patch = %v, want empty (nothing written on disk yet)", view.Patch)
}
if view.Rules.WindowMs != [2]int{runtimeconfig.CoalesceWindowMinMs, runtimeconfig.CoalesceWindowMaxMs} {
t.Fatalf("rules.windowMs = %v, want [300 15000]", view.Rules.WindowMs)
}
if len(view.Rules.FlushReasons) == 0 {
t.Fatalf("rules.flushReasons is empty, want the closed set of departure reasons")
}
}
func TestAdminCoalesceWriteIsThreeState(t *testing.T) {
t.Parallel()
e := newCoalesceEnv(t)
token := signAny(t, e.jwt, "acct-owner", auth.RoleOwner)
// ① 合法值 ⇒ 采用并落盘。
resp, body := doRaw(t, http.MethodPut, e.url, `{"default":{"windowMs":3000}}`, token)
if resp.StatusCode != http.StatusOK {
t.Fatalf("PUT status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if view := e.view(t, body); view.Default.WindowMs != 3000 || !view.Default.Enabled || !view.Present {
t.Fatalf("after PUT default = %+v, want windowMs 3000, the rest at defaults and present:true", view.Default)
}
before, ok := e.disk(t)
if !ok {
t.Fatal("PUT reported success but no file was written")
}
// ② 缺位键 ⇒ 不动(同样的字节 ⇒ 连文件都不重写)。
resp, body = doRaw(t, http.MethodPut, e.url, `{}`, token)
if resp.StatusCode != http.StatusOK {
t.Fatalf("empty PUT status = %d, want 200", resp.StatusCode)
}
if after, _ := e.disk(t); string(after) != string(before) {
t.Fatalf("empty patch rewrote the file:\nbefore=%s\nafter=%s", before, after)
}
// ③ 非法值(负数)⇒ 400 且盘上零变更。
resp, body = doRaw(t, http.MethodPut, e.url, `{"default":{"windowMs":-5}}`, token)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("negative windowMs status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
assertErrorCode(t, body, "invalid_payload")
if after, _ := e.disk(t); string(after) != string(before) {
t.Fatalf("invalid patch touched the file:\nbefore=%s\nafter=%s", before, after)
}
// ④ 未知键 ⇒ 400 且盘上零变更。
resp, body = doRaw(t, http.MethodPut, e.url, `{"default":{"windowMsTypo":1}}`, token)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("unknown key status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
if after, _ := e.disk(t); string(after) != string(before) {
t.Fatalf("unknown key touched the file:\nbefore=%s\nafter=%s", before, after)
}
}
func TestAdminCoalesceConversationOverridesAndDelete(t *testing.T) {
t.Parallel()
e := newCoalesceEnv(t)
token := signAny(t, e.jwt, "acct-admin", auth.RoleAdmin)
// 每会话覆盖:只有给出的键被采用,其余跟随全局生效值。
resp, body := doRaw(t, http.MethodPut, e.url,
`{"default":{"windowMs":2500},"conversations":{"telegram|group:-20":{"enabled":false}}}`, token)
if resp.StatusCode != http.StatusOK {
t.Fatalf("PUT status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
view := e.view(t, body)
conv, ok := view.Conversations["telegram|group:-20"]
if !ok {
t.Fatalf("conversation override missing from the view: %+v", view.Conversations)
}
if conv.Enabled || conv.WindowMs != 2500 {
t.Fatalf("conversation policy = %+v, want enabled:false and windowMs inherited (2500)", conv)
}
// null ⇒ 删掉该会话覆盖(回到全局)。
resp, body = doRaw(t, http.MethodPut, e.url, `{"conversations":{"telegram|group:-20":null}}`, token)
if resp.StatusCode != http.StatusOK {
t.Fatalf("delete override status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if view := e.view(t, body); len(view.Conversations) != 0 {
t.Fatalf("conversations = %+v, want none after the null delete", view.Conversations)
}
}
func TestAdminCoalesceWriteRequiresOwnerOrAdmin(t *testing.T) {
t.Parallel()
e := newCoalesceEnv(t)
viewer := signAny(t, e.jwt, "acct-viewer", auth.RoleViewer)
resp, body := doRaw(t, http.MethodPut, e.url, `{"default":{"windowMs":3000}}`, viewer)
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("viewer PUT status = %d, want 403 (body=%s)", resp.StatusCode, body)
}
if _, ok := e.disk(t); ok {
t.Fatal("a forbidden write still created the config file")
}
}
+7
View File
@@ -269,6 +269,13 @@ func (s *Server) registerAdminRoutes(
// 文件,不是身份/凭据面)。见 admin_memory_tuning.go。
mux.Handle("GET /api/v1/admin/memories/tidy/tuning", writeAuth(http.HandlerFunc(s.adminMemoryTidyTuningRead)))
mux.Handle("PUT /api/v1/admin/memories/tidy/tuning", writeAuth(http.HandlerFunc(s.adminMemoryTidyTuningWrite)))
// 渠道静默窗口合并(M23;见 admin_coalesce.go):读回 `$DATA_DIR/config/coalesce.json`
// 的窗口参数 + 取值规则 + 「主人私聊不受影响」这条只读事实;写是同一条路径上的
// **三态补丁**(键缺位 = 不动 / 合法值 = 采用 / 非法值 = 400 且盘上不动)+ 原子落盘
// + 审计(channels.coalesce)。两条都走 writeAuth(owner|admin)—— 与上面那条
// memory-tidy 调优同档:写的是面板配置文件,不是身份/凭据面。
mux.Handle("GET /api/v1/admin/channels/coalesce", writeAuth(http.HandlerFunc(s.adminCoalesceRead)))
mux.Handle("PUT /api/v1/admin/channels/coalesce", writeAuth(http.HandlerFunc(s.adminCoalesceWrite)))
mux.Handle("GET /api/v1/admin/observability/audit", writeAuth(http.HandlerFunc(s.adminObservabilityAudit)))
mux.Handle("GET /api/v1/admin/sessions/{id}/export", writeAuth(http.HandlerFunc(s.adminExportSession)))
mux.Handle("POST /api/v1/admin/rag/query", writeAuth(http.HandlerFunc(s.adminSearchRag)))