feat(backthink): 静默时段心跳降频(A)—— THINK_QUIET_INTERVAL_SEC 默认 3600

主人裁定「先做 A(静默降频)再转向 C」;背景实测:静默期**思考频率不变**(恒定 900s 心跳),只拦打扰 ⇒ 成本照付。
- 新键 THINK_QUIET_INTERVAL_SEC(默认 3600):窗口内用该间隔、窗外仍用 THINK_INTERVAL_SEC;**≤0 = 关闭降频**(不许出现「半夜完全不思考」暗坑);窗口关闭/钟点非法 ⇒ 不降频
- 心跳**每拍按时刻选间隔**(放到循环外就永远不生效——变异实测能打红)
- 🔴 不变量:**事件驱动不被降频影响**(TestBackthink_EventTrigger_NotSlowedByQuietInterval 钉住)
- 事实卡 9→12 条(+思考总开关/普通间隔/静默间隔,标「改需重启」);契约形状未变、未升版
- ⚠️ 如实登记:ENABLE_BACKGROUND_THINKING 是**死配置**(全仓无消费者),已在 env/面板标签/契约三处标注,**是否接线待主人拍**
- 变异 5 条全红;C 的缺口清单(唤醒面 7 处)已回给主会话排期
This commit is contained in:
AskaEth committed 2026-10-05 22:38:17 +08:00
1 parent d6ed9cfa2b
commit 236b202fda
13 files changed
+633 -54

No files matched your search

@@ -39,6 +39,9 @@ const FACTS: AdminConfigFacts = {
{ key: "MEMORY_TIDY_RECALL_THRESHOLD", value: "0.8", default: "0.8", isDefault: true },
{ key: "MEMORY_TIDY_SCAN_LIMIT", value: "5000", default: "5000", isDefault: true },
{ key: "MEMORY_TIDY_CANDIDATE_LIMIT", value: "20", default: "20", isDefault: true },
{ key: "ENABLE_BACKGROUND_THINKING", value: "true", default: "true", isDefault: true },
{ key: "THINK_INTERVAL_SEC", value: "7200", default: "900", isDefault: false },
{ key: "THINK_QUIET_INTERVAL_SEC", value: "0", default: "3600", isDefault: false },
{ key: "QUIET_START_HOUR", value: "1", default: "23", isDefault: false },
{ key: "QUIET_END_HOUR", value: "5", default: "7", isDefault: false },
],
@@ -76,6 +79,16 @@ ok(has(quietHtml, "QUIET_START_HOUR") && has(quietHtml, "QUIET_END_HOUR"), "列
ok(has(quietHtml, "1 时") && has(quietHtml, "5 时"), "显示进程生效的钟点");
ok(has(quietHtml, "已改"), "被改过的窗口钟点标「已改」");
// ── 心跳节拍(design/12 §N A4):3 个键 + 「降频只作用于心跳」+ 「改需重启」 ──
ok(has(quietHtml, "THINK_QUIET_INTERVAL_SEC"), "必须列出降频键真名(用户照着它改 .env)");
ok(has(quietHtml, "THINK_INTERVAL_SEC"), "必须列出普通心跳周期键名");
ok(has(quietHtml, "ENABLE_BACKGROUND_THINKING"), "必须列出自主思考总开关键名");
ok(has(quietHtml, "0 秒"), "≤0 的生效值如实显示(关闭降频,不编默认值)");
ok(has(quietHtml, "7200 秒"), "普通心跳周期如实显示生效值");
ok(has(quietHtml, "例行自主思考的心跳节拍"), "心跳节拍必须与安静时段同卡说明");
ok(has(quietHtml, "事件驱动"), "必须说清降频不影响事件驱动通路(安全域照旧立刻触发)");
ok(has(quietHtml, "重启服务"), "必须写明这些键改后要重启(不许假装可热改)");
// ── 关闭态的窗口:必须说「从不生效」,不能画成假窗口 ────────────────────────
const disabledHtml = renderToStaticMarkup(
createElement(QuietHoursFactsBody, {
@@ -4,6 +4,8 @@ import { useAsyncData } from "../../lib/useAsyncData";
import { Notice } from "../components";
import { Surface, Tag } from "../../components/primitives";
import {
heartbeatPurpose,
heartbeatRows,
memoryTidyRows,
quietHoursPurpose,
quietHoursRows,
@@ -93,9 +95,16 @@ export function QuietHoursFactsBody({ facts }: { facts: AdminConfigFacts | null
</div>
<FactsLines rows={quietHoursRows(facts)} />
<p className="text-xs text-[var(--mu-text-mid)]">{quietHoursPurpose()}</p>
<div className="border-t border-[var(--mu-glass-border)] pt-2">
<span className="text-xs text-[var(--mu-text-mid)]">例行自主思考的心跳节拍</span>
<FactsLines rows={heartbeatRows(facts)} />
</div>
<p className="text-xs text-[var(--mu-text-mid)]">{heartbeatPurpose()}</p>
<p className="text-xs text-[var(--mu-text-lo)]">
只读事实(值 = 进程生效值,不是 <span className="font-mono">.env</span> 原文):改窗口请编辑{" "}
<span className="font-mono">QUIET_START_HOUR</span> / <span className="font-mono">QUIET_END_HOUR</span> 后重启服务。
只读事实(值 = 进程生效值,不是 <span className="font-mono">.env</span> 原文):这些键都在启动期读取,
改值请编辑 <span className="font-mono">QUIET_START_HOUR</span> /{" "}
<span className="font-mono">QUIET_END_HOUR</span> / <span className="font-mono">THINK_INTERVAL_SEC</span> /{" "}
<span className="font-mono">THINK_QUIET_INTERVAL_SEC</span> 后重启服务(不支持热改)。
</p>
</>
);
@@ -14,10 +14,13 @@
import type { AdminConfigFacts } from "../../lib/types";
import {
BACKTHINK_HEARTBEAT_KEYS,
FACT_MISSING_VALUE,
MEMORY_TIDY_KEYS,
QUIET_HOURS_KEYS,
factRows,
heartbeatPurpose,
heartbeatRows,
memoryTidyRows,
quietHoursPurpose,
quietHoursRows,
@@ -52,6 +55,9 @@ const DEFAULTS: AdminConfigFacts = {
{ key: "MEMORY_TIDY_RECALL_THRESHOLD", value: "0.8", default: "0.8", isDefault: true },
{ key: "MEMORY_TIDY_SCAN_LIMIT", value: "5000", default: "5000", isDefault: true },
{ key: "MEMORY_TIDY_CANDIDATE_LIMIT", value: "20", default: "20", isDefault: true },
{ key: "ENABLE_BACKGROUND_THINKING", value: "true", default: "true", isDefault: true },
{ key: "THINK_INTERVAL_SEC", value: "900", default: "900", isDefault: true },
{ key: "THINK_QUIET_INTERVAL_SEC", value: "3600", default: "3600", isDefault: true },
{ key: "QUIET_START_HOUR", value: "23", default: "23", isDefault: true },
{ key: "QUIET_END_HOUR", value: "7", default: "7", isDefault: true },
],
@@ -133,5 +139,42 @@ const unknown = factRows(DEFAULTS, ["NOT_A_REAL_KEY"]);
same(unknown[0].label, "NOT_A_REAL_KEY", "没有元数据的键用键名兜底(不编中文标签)");
same(unknown[0].value, FACT_MISSING_VALUE, "没有元数据的键如实「未报告」");
// ── ⑥ 后台思考心跳:3 行 + 静默降频的用途文案(design/12 §N A4) ─────────────
same(
heartbeatRows(DEFAULTS).map((r) => r.key),
[...BACKTHINK_HEARTBEAT_KEYS],
"心跳 3 行,键序与后端事实表一致",
);
same(
heartbeatRows(DEFAULTS).find((r) => r.key === "THINK_QUIET_INTERVAL_SEC")?.value,
"3600 秒",
"降频间隔带单位显示,数值原样取后端",
);
same(
heartbeatRows(DEFAULTS).find((r) => r.key === "ENABLE_BACKGROUND_THINKING")?.value,
"true",
"总开关原样显示后端布尔值",
);
// ⚠️ 诚实边界:这个开关**未接线**(全仓无消费者,DevDocs/audit/07 §1-17)。
// 面板必须说清「配了不生效」,否则它会把一个死开关显示成生效开关。
ok(
has(
heartbeatRows(DEFAULTS).find((r) => r.key === "ENABLE_BACKGROUND_THINKING")?.label ?? "",
"未接线",
),
"未接线的总开关必须在标签里标明,不能显示成生效开关",
);
same(heartbeatRows(null).length, 3, "整份响应都没拿到时,3 行仍然都在(全为未报告)");
ok(
heartbeatRows(null).every((r) => r.state === FACT_MISSING_VALUE),
"没有响应时全部如实标「未报告」,不编 0 或默认值",
);
const hbPurpose = heartbeatPurpose();
ok(has(hbPurpose, "只把「例行自主思考」的心跳放慢"), "必须点明降频只作用于周期性心跳");
ok(has(hbPurpose, "事件驱动"), "必须点明事件驱动通路不受影响(安全域照旧立刻触发)");
ok(has(hbPurpose, "≤0"), "必须说明 ≤0 = 关闭降频");
ok(has(hbPurpose, "不是「半夜完全不思考」"), "不得把 ≤0 说成「半夜完全不思考」");
// eslint-disable-next-line no-console
console.log(`configFacts: ${checks} checks passed`);
@@ -27,6 +27,17 @@ export const MEMORY_TIDY_KEYS: readonly string[] = [
/** 安静时段的两个键(后端事实表后两条)。 */
export const QUIET_HOURS_KEYS: readonly string[] = ["QUIET_START_HOUR", "QUIET_END_HOUR"];
/**
* 后台思考心跳的 3 个键(design/12 §N A4;与后端 configFactSpecs 同序)。
* ⚠️ 它们与 `QUIET_*` 同属「例行自主思考」的节奏旋钮:env 是**启动期**读的,
* 改了要重启才生效(面板文案必须写明,不许假装可热改)。
*/
export const BACKTHINK_HEARTBEAT_KEYS: readonly string[] = [
"ENABLE_BACKGROUND_THINKING",
"THINK_INTERVAL_SEC",
"THINK_QUIET_INTERVAL_SEC",
];
export interface FactRow {
key: string;
/** 中文说明(这个键管什么)。 */
@@ -51,6 +62,11 @@ const FACT_META: Record<string, FactMeta> = {
MEMORY_TIDY_RECALL_THRESHOLD: { label: "候选召回门(余弦)" },
MEMORY_TIDY_SCAN_LIMIT: { label: "每轮扫描上限" },
MEMORY_TIDY_CANDIDATE_LIMIT: { label: "每轮候选上限" },
// ⚠️ 未接线(全仓无消费者,见 DevDocs/audit/07 §1-17):面板必须照实说,
// 不许把一个配了不生效的开关显示成「生效值」。
ENABLE_BACKGROUND_THINKING: { label: "自主思考总开关(未接线,配了不生效)" },
THINK_INTERVAL_SEC: { label: "心跳周期", unit: " 秒" },
THINK_QUIET_INTERVAL_SEC: { label: "静默时段心跳周期(≤0 = 不降频)", unit: " 秒" },
QUIET_START_HOUR: { label: "安静时段起点(本地钟点)", unit: " 时" },
QUIET_END_HOUR: { label: "安静时段终点(本地钟点)", unit: " 时" },
};
@@ -89,6 +105,23 @@ export function quietHoursRows(facts: AdminConfigFacts | null): FactRow[] {
return factRows(facts, QUIET_HOURS_KEYS);
}
/**
* 后台思考心跳事实行(3 条,design/12 §N A4)。
* 与安静时段同卡渲染:它们都是「例行自主思考」的节拍旋钮。
*/
export function heartbeatRows(facts: AdminConfigFacts | null): FactRow[] {
return factRows(facts, BACKTHINK_HEARTBEAT_KEYS);
}
/**
* 后台思考节拍的**一句话用途文案**(design/12 §N A2/A3)。
* 必须同时说清两件容易误读的事:降频**只作用于周期性心跳**;
* `≤0` 是「不降频」而不是「半夜不思考」。
*/
export function heartbeatPurpose(): string {
return "安静时段只把「例行自主思考」的心跳放慢(默认 900 秒 → 3600 秒),事件驱动的那条路不受影响:安全域 / 急性事件照旧立刻触发,照样能穿透安静时段。静默间隔写成 ≤0 就是关闭降频(回落普通间隔),不是「半夜完全不思考」。";
}
/** 安静时段窗口的一句话摘要(后端 label + 跨午夜 / 已关闭 / 「0/0 = 未填」的如实补充)。 */
export function quietHoursSummary(qh: AdminConfigQuietHours | null | undefined): string {
if (!qh) return FACT_MISSING_VALUE;