diff --git a/cmd/cyrene/wire_channels.go b/cmd/cyrene/wire_channels.go index 9bb5e33..2862ef6 100644 --- a/cmd/cyrene/wire_channels.go +++ b/cmd/cyrene/wire_channels.go @@ -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) diff --git a/frontend/web/src/admin/modules/channels/CoalesceCard.tsx b/frontend/web/src/admin/modules/channels/CoalesceCard.tsx new file mode 100644 index 0000000..8224bef --- /dev/null +++ b/frontend/web/src/admin/modules/channels/CoalesceCard.tsx @@ -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; + conversations: Record; + 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 }; errors: Record; changed: string[] } { + const errors: Record = {}; + const out: Record = {}; + 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 { + return request("/admin/channels/coalesce"); +} + +/** saveCoalesce 提交三态补丁(返回**重新读盘**后的真值)。 */ +export function saveCoalesce(patch: { default: Record }): Promise { + return request("/admin/channels/coalesce", { + method: "PUT", + body: JSON.stringify(patch), + }); +} + +/** BodyProps 是展示半边的输入(全部状态由外面给,便于直接渲染断言)。 */ +export interface CoalesceBodyProps { + view: CoalesceView; + draft: CoalesceDraft; + errors: Record; + 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 ( +
+
+ {COALESCE_HOT_LABEL} + {view.path} + + {view.present ? "面板配置已存在" : "面板配置还没写过(运行期走代码默认:2.0s / 6s / 24 条)"} + +
+ {OWNER_PRIVATE_NOTE} + {COALESCE_FIELDS.map((field) => ( +
+
+ {field.label} + {field.kind === "bool" ? ( + + ) : ( + onDraftChange(field.key, e.target.value)} + /> + )} + {field.key} +
+
{field.hint}
+ {errors[field.key] ?
⚠ {errors[field.key]}
: null} +
+ ))} +
+ + {hasErrors ? ( + 有坏值,未提交(后端一个字都不会改) + ) : null} +
+ {receipt ? {receipt} : null} + {savedView ? ( +
+ 保存后回显(后端重新读盘的真值): +
+ 窗口 {savedView.default.windowMs}ms · 上限 {savedView.default.maxWaitMs}ms · 单批{" "} + {savedView.default.maxBatchMessages} 条 · {savedView.default.enabled ? "已启用" : "已关闭"} +
+
{savedView.hot ? COALESCE_HOT_LABEL : "已落盘,但未能热生效"}
+
+ ) : null} + {Object.keys(view.conversations).length > 0 ? ( +
+ 每会话覆盖(在配置文件里维护,键 = 平台|渠道): + {Object.entries(view.conversations).map(([key, policy]) => ( +
+ {key} → {policy.enabled ? "启用" : "关闭"} · 窗口 {policy.windowMs}ms · 单批 {policy.maxBatchMessages} 条 +
+ ))} +
+ ) : null} +

+ 规则:窗口 {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(" / ")}。 +

+
+ ); +} + +/** ChannelCoalesceCard 负责取数、表单草稿与保存。 */ +export function ChannelCoalesceCard(): React.ReactNode { + const [view, setView] = useState(null); + const [draft, setDraft] = useState(null); + const [errors, setErrors] = useState>({}); + const [busy, setBusy] = useState(false); + const [fail, setFail] = useState(null); + const [receipt, setReceipt] = useState(null); + const [savedView, setSavedView] = useState(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 ( + +
+

群聊静默窗口合并

+ GET / PUT /api/v1/admin/channels/coalesce +
+ {fail ? {fail} : null} + {view === null || draft === null ? ( + {fail ? "读取失败" : "读取中…"} + ) : ( + + )} +
+ ); +} diff --git a/frontend/web/src/admin/modules/channels/OverviewPage.tsx b/frontend/web/src/admin/modules/channels/OverviewPage.tsx index 1bf3c06..9d94c65 100644 --- a/frontend/web/src/admin/modules/channels/OverviewPage.tsx +++ b/frontend/web/src/admin/modules/channels/OverviewPage.tsx @@ -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 改文件。 */} + ); } diff --git a/frontend/web/src/admin/modules/channels/coalesceCard.render.test.ts b/frontend/web/src/admin/modules/channels/coalesceCard.render.test.ts new file mode 100644 index 0000000..880cc09 --- /dev/null +++ b/frontend/web/src/admin/modules/channels/coalesceCard.render.test.ts @@ -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 { + 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>): 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(); diff --git a/internal/config/runtimeconfig/channels_coalesce.go b/internal/config/runtimeconfig/channels_coalesce.go new file mode 100644 index 0000000..e79fded --- /dev/null +++ b/internal/config/runtimeconfig/channels_coalesce.go @@ -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 +} diff --git a/internal/services/channelbridge/channelbridge.go b/internal/services/channelbridge/channelbridge.go index 9110dbb..d81dfba 100644 --- a/internal/services/channelbridge/channelbridge.go +++ b/internal/services/channelbridge/channelbridge.go @@ -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 } diff --git a/internal/services/channelbridge/coalesce.go b/internal/services/channelbridge/coalesce.go new file mode 100644 index 0000000..e31f070 --- /dev/null +++ b/internal/services/channelbridge/coalesce.go @@ -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) + } +} diff --git a/internal/services/channelbridge/coalesce_batch.go b/internal/services/channelbridge/coalesce_batch.go new file mode 100644 index 0000000..7dae605 --- /dev/null +++ b/internal/services/channelbridge/coalesce_batch.go @@ -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 +} diff --git a/internal/services/channelbridge/coalesce_continuation_test.go b/internal/services/channelbridge/coalesce_continuation_test.go new file mode 100644 index 0000000..f87eb3c --- /dev/null +++ b/internal/services/channelbridge/coalesce_continuation_test.go @@ -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) + } +} diff --git a/internal/services/channelbridge/coalesce_fixture_test.go b/internal/services/channelbridge/coalesce_fixture_test.go new file mode 100644 index 0000000..b27cd98 --- /dev/null +++ b/internal/services/channelbridge/coalesce_fixture_test.go @@ -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) +} diff --git a/internal/services/channelbridge/coalesce_lane.go b/internal/services/channelbridge/coalesce_lane.go new file mode 100644 index 0000000..cc5f64a --- /dev/null +++ b/internal/services/channelbridge/coalesce_lane.go @@ -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) +} diff --git a/internal/services/channelbridge/coalesce_owner_test.go b/internal/services/channelbridge/coalesce_owner_test.go new file mode 100644 index 0000000..59c2772 --- /dev/null +++ b/internal/services/channelbridge/coalesce_owner_test.go @@ -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) + } +} diff --git a/internal/services/channelbridge/coalesce_prepare.go b/internal/services/channelbridge/coalesce_prepare.go new file mode 100644 index 0000000..3e9495c --- /dev/null +++ b/internal/services/channelbridge/coalesce_prepare.go @@ -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) } +} diff --git a/internal/services/channelbridge/coalesce_test.go b/internal/services/channelbridge/coalesce_test.go new file mode 100644 index 0000000..ef60208 --- /dev/null +++ b/internal/services/channelbridge/coalesce_test.go @@ -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") +} diff --git a/internal/services/channelbridge/inbound.go b/internal/services/channelbridge/inbound.go index 3c0bdec..ab34298 100644 --- a/internal/services/channelbridge/inbound.go +++ b/internal/services/channelbridge/inbound.go @@ -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) { diff --git a/internal/services/channelbridge/lane.go b/internal/services/channelbridge/lane.go index 77adc15..0f591fd 100644 --- a/internal/services/channelbridge/lane.go +++ b/internal/services/channelbridge/lane.go @@ -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() diff --git a/internal/services/channelbridge/lane_test.go b/internal/services/channelbridge/lane_test.go index 494d36c..de028fb 100644 --- a/internal/services/channelbridge/lane_test.go +++ b/internal/services/channelbridge/lane_test.go @@ -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) diff --git a/internal/services/channelbridge/stranger_test.go b/internal/services/channelbridge/stranger_test.go index a98c88d..6a6b73e 100644 --- a/internal/services/channelbridge/stranger_test.go +++ b/internal/services/channelbridge/stranger_test.go @@ -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) } } diff --git a/internal/services/gateway/admin_coalesce.go b/internal/services/gateway/admin_coalesce.go new file mode 100644 index 0000000..953a957 --- /dev/null +++ b/internal/services/gateway/admin_coalesce.go @@ -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, + } +} diff --git a/internal/services/gateway/admin_coalesce_test.go b/internal/services/gateway/admin_coalesce_test.go new file mode 100644 index 0000000..bcb86e7 --- /dev/null +++ b/internal/services/gateway/admin_coalesce_test.go @@ -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") + } +} diff --git a/internal/services/gateway/routes.go b/internal/services/gateway/routes.go index 517af3b..4867aa9 100644 --- a/internal/services/gateway/routes.go +++ b/internal/services/gateway/routes.go @@ -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)))