dsh-session-guard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/retry.js ADDED
@@ -0,0 +1,202 @@
1
+ /**
2
+ * dsh-session-guard — 后端自动重试(host side,D9)。
3
+ *
4
+ * 监听 session/event:turn/end 以 error / interrupted / max-tokens 结束时,
5
+ * 分类瞬时/永久失败,自适应退避后自动 `agent.followup(retryText)` 续跑。
6
+ *
7
+ * 关键纪律:
8
+ * - **冻结让路**:会话被本插件门控(高峰暂停 / 队列锁)时不触发重试——
9
+ * 与 input-traffic「freeze 是一等公民」一致,自动重试不得绕过会话门。
10
+ * - 用户手动介入(user/message)与成功回合重置连续计数。
11
+ * - 子代理会话不重试(由父代理处理)。
12
+ * - 永久失败(鉴权/余额/模型不存在/上下文超限等)重试无益 → 停止并告警。
13
+ *
14
+ * 纯决策逻辑(classifyTurnEnd / isTransientFailure / effectiveCooldown /
15
+ * shouldRetry)零依赖可单测;事件接线在 createRetry。
16
+ */
17
+
18
+ /** 默认重试配置。 */
19
+ export const DEFAULT_RETRY = Object.freeze({
20
+ retryEnabled: false, // ← 自动重试开关(默认关,保守)
21
+ retryText: '继续(自动重试)',
22
+ retryGraceMs: 3000, // 失败后等待多久再发
23
+ retryCooldownMs: 20000, // 同一会话两次重试最小间隔
24
+ retryBackoffFactor: 2,
25
+ retryBackoffMaxMs: 300000,
26
+ retryMaxConsecutive: 3, // 连续重试上限,超过停止
27
+ })
28
+
29
+ /** 瞬时 vs 永久失败分类:瞬时值得重试,永久重试无益。 */
30
+ export function isTransientFailure({ code, message, status } = {}) {
31
+ const haystack = `${code ?? ''} ${message ?? ''}`.toLowerCase()
32
+ if (status !== undefined && (status === 401 || status === 403)) return false
33
+ const permanent =
34
+ /auth|unauthor|forbidden|credential|api\s*[_-]?\s*key|permission/i.test(haystack) ||
35
+ /insufficient.*(balance|quota)|billing|payment/i.test(haystack) ||
36
+ /model[^a-z]*not[^a-z]*found|unknown[_-]?model|not.*support.*model/i.test(haystack) ||
37
+ /context.*(length|limit|overflow|exceed)|token.*limit|max.*context/i.test(haystack) ||
38
+ /invalid[_-]?request|bad[_-]?request/i.test(haystack)
39
+ return !permanent
40
+ }
41
+
42
+ /**
43
+ * turn/end reason → 是否可自动重试。
44
+ * - completed / aborted(用户停)/ blocked(策略拒)→ 否
45
+ * - error → 按 isTransientFailure 分类
46
+ * - interrupted(崩溃修复)→ 可重试
47
+ * - max-tokens → 可重试
48
+ */
49
+ export function classifyTurnEnd(reason, failure) {
50
+ const kind = reason && reason.kind
51
+ if (kind === 'completed' || kind === 'aborted' || kind === 'blocked') return false
52
+ if (kind === 'error') return isTransientFailure(failure)
53
+ if (kind === 'interrupted' || kind === 'max-tokens') return true
54
+ return false
55
+ }
56
+
57
+ /** 自适应退避:consecutive 次连续后 cooldown * factor^n,封顶 max。 */
58
+ export function effectiveCooldown(consecutive, base, factor, max) {
59
+ const mult = Math.pow(factor, Math.max(0, consecutive))
60
+ return Math.min(Math.max(base, base * mult), Math.max(base, max))
61
+ }
62
+
63
+ /**
64
+ * 纯决策:此刻是否应触发重试。
65
+ * @param {object} s 会话状态 { consecutive, lastAttemptAt, pending }
66
+ * @param {object} cfg 重试配置
67
+ * @param {boolean} frozen 会话是否被门控(高峰暂停/队列锁)
68
+ * @param {number} now
69
+ */
70
+ export function shouldRetry(s, cfg, frozen, now = Date.now()) {
71
+ if (!cfg.retryEnabled) return false
72
+ if (frozen) return false // 冻结让路(D9)
73
+ if (s.pending) return false // 已有排队重试
74
+ if (s.consecutive >= cfg.retryMaxConsecutive) return false
75
+ if (now - s.lastAttemptAt < effectiveCooldown(s.consecutive, cfg.retryCooldownMs, cfg.retryBackoffFactor, cfg.retryBackoffMaxMs)) return false
76
+ return true
77
+ }
78
+
79
+ /** 会话状态工厂。 */
80
+ export function freshRetryState() {
81
+ return { consecutive: 0, lastAttemptAt: 0, pending: false }
82
+ }
83
+
84
+ /**
85
+ * 事件接线(host):
86
+ * @param {object} deps
87
+ * @param {object} deps.ctx host context
88
+ * @param {()=>object} deps.getSettings 读实时配置
89
+ * @param {(sessionId:string)=>boolean} deps.isFrozen 会话是否被门控
90
+ * @param {(sessionId:string, text:string)=>void} [deps.send] 发送函数(默认 agent.followup)
91
+ */
92
+ export function createRetry({ ctx, getSettings, isFrozen, send }) {
93
+ const states = new Map()
94
+
95
+ function state(sessionId) {
96
+ let s = states.get(sessionId)
97
+ if (!s) {
98
+ s = freshRetryState()
99
+ states.set(sessionId, s)
100
+ }
101
+ return s
102
+ }
103
+
104
+ /** 从 turn/end reason 提取失败事实。 */
105
+ function failureFacts(reason) {
106
+ const error = reason && reason.error
107
+ return {
108
+ code: error && typeof error.code === 'string' ? error.code : 'UNKNOWN',
109
+ message: error && typeof error.message === 'string' ? error.message : '',
110
+ status: error && typeof error.status === 'number' ? error.status : undefined,
111
+ }
112
+ }
113
+
114
+ function schedule(sessionId, reason) {
115
+ const s = state(sessionId)
116
+ const cfg = getSettings()
117
+ if (s.pending) return
118
+ const frozen = isFrozen(sessionId)
119
+ if (!shouldRetry({ ...s, pending: true }, cfg, frozen)) return
120
+ s.pending = true
121
+ const grace = cfg.retryGraceMs ?? DEFAULT_RETRY.retryGraceMs
122
+ const timer = setTimeout(() => {
123
+ s.pending = false
124
+ // 到点再复核:门控/上限变化后放弃。
125
+ if (!shouldRetry(s, cfg, isFrozen(sessionId))) return
126
+ const agent = ctx.agents.get(sessionId)
127
+ if (!agent || agent.status !== 'idle') return
128
+ const text = cfg.retryText ?? DEFAULT_RETRY.retryText
129
+ try {
130
+ if (send) {
131
+ send(sessionId, text)
132
+ } else {
133
+ // 与 autoresume 同构:直接构造 plugin-source 消息,避免运行时依赖。
134
+ agent.followup({
135
+ id: `session-guard-retry-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
136
+ role: 'user',
137
+ content: [{ type: 'text', text }],
138
+ source: { kind: 'plugin', plugin: 'session-guard', form: 'notice' },
139
+ })
140
+ }
141
+ s.lastAttemptAt = Date.now()
142
+ s.consecutive += 1
143
+ ctx.logger?.info?.(`[session-guard] auto-retry ${sessionId} (${String(reason && reason.kind)}), #${s.consecutive}`)
144
+ } catch (e) {
145
+ ctx.logger?.warn?.(`[session-guard] auto-retry send failed: ${String(e && e.message || e)}`)
146
+ }
147
+ }, grace)
148
+ // 会话对象上的清理钩子(若宿主提供)。
149
+ const dispose = () => clearTimeout(timer)
150
+ return dispose
151
+ }
152
+
153
+ function onEvent(session, event) {
154
+ const sessionId = session && session.id
155
+ if (typeof sessionId !== 'string') return
156
+ const s = state(sessionId)
157
+ switch (event.type) {
158
+ case 'turn/end': {
159
+ const reason = event.data && event.data.reason
160
+ if (reason && reason.kind === 'completed') {
161
+ s.consecutive = 0 // 成功回合重置
162
+ s.pending = false
163
+ return
164
+ }
165
+ if (reason && reason.kind === 'aborted') {
166
+ // 用户主动停止:不重试,重置。
167
+ s.consecutive = 0
168
+ s.pending = false
169
+ return
170
+ }
171
+ if (reason && classifyTurnEnd(reason, failureFacts(reason))) {
172
+ schedule(sessionId, reason)
173
+ }
174
+ return
175
+ }
176
+ case 'user/message': {
177
+ // 用户手动介入:重置(无论是否我们的回显都保守重置)。
178
+ if (event.data && event.data.source && event.data.source.kind === 'user') {
179
+ s.consecutive = 0
180
+ s.pending = false
181
+ }
182
+ return
183
+ }
184
+ default:
185
+ return
186
+ }
187
+ }
188
+
189
+ ctx.on('session/event', (session, event) => {
190
+ try {
191
+ onEvent(session, event)
192
+ } catch (e) {
193
+ ctx.logger?.warn?.(`[session-guard] retry event failed: ${String(e && e.message || e)}`)
194
+ }
195
+ })
196
+
197
+ return {
198
+ onEvent,
199
+ state,
200
+ states,
201
+ }
202
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * dsh-session-guard — 纯状态机(可单测,零依赖)。
3
+ *
4
+ * 状态:NORMAL ↔ PAUSED_PEAK。
5
+ * 副作用(暂停/恢复具体会话)由 host 的 tick 执行,这里只做判定与迁移检测。
6
+ */
7
+ import { shouldPause } from './time.js'
8
+
9
+ export const STATES = {
10
+ NORMAL: 'NORMAL',
11
+ PAUSED_PEAK: 'PAUSED_PEAK',
12
+ }
13
+
14
+ /**
15
+ * 计算当前状态。
16
+ * @param {object} settings 同 time.shouldPause 的 settings
17
+ * @param {Date|number} now
18
+ * @returns {{state:string, reason:string, at:number}}
19
+ */
20
+ export function computeState(settings, now) {
21
+ const date = now instanceof Date ? now : new Date(now)
22
+ const d = shouldPause(settings, date)
23
+ return {
24
+ state: d.pause ? STATES.PAUSED_PEAK : STATES.NORMAL,
25
+ reason: d.reason,
26
+ at: date.getTime(),
27
+ }
28
+ }
29
+
30
+ /**
31
+ * 迁移检测:从 prev 到 next 是否发生了 入峰(enter) / 退峰(leave)。
32
+ * @param {{state:string}|null} prev
33
+ * @param {{state:string}} next
34
+ * @returns {{enter:boolean, leave:boolean}}
35
+ */
36
+ export function transition(prev, next) {
37
+ const was = prev && prev.state
38
+ return {
39
+ enter: next.state === STATES.PAUSED_PEAK && was !== STATES.PAUSED_PEAK,
40
+ leave: was === STATES.PAUSED_PEAK && next.state !== STATES.PAUSED_PEAK,
41
+ }
42
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * dsh-session-guard — 设置子板块(设置 → 插件 → session-guard,简单开关)。
3
+ *
4
+ * 注册方式对齐 dsh-thinking-levels 的官方做法:
5
+ * - `@deepseek-ai/schemastery` 为**常规 dependency**(随插件安装,可解析)。
6
+ * - **不 value-import `@deepseek-ai/dsh-settings`**:该服务由 dsh runtime 经
7
+ * cordis `settings` 注入面提供,不属于 profile 的可解析树;这里只用本地最小
8
+ * 接口(SettingsScopeLike / SettingsServiceLike / SettingsAwareCtx)在
9
+ * `ctx.inject(['settings'], ...)` 里注册命名空间,`base` 层叠组合配置。
10
+ * - 仍 fail-open:任何解析 / 注入失败都静默降级用 DEFAULT_SETTINGS,绝不因
11
+ * 设置依赖而崩。
12
+ */
13
+ import z from '@deepseek-ai/schemastery'
14
+ import { DEFAULT_RETRY } from './retry.js'
15
+
16
+ /** 设置命名空间(设置 → 插件 → session-guard)。 */
17
+ export const NS = 'session-guard'
18
+
19
+ /** 默认配置(核心逻辑依赖;设置服务不可用时即用此值)。 */
20
+ export const DEFAULT_SETTINGS = Object.freeze({
21
+ enabled: true, // 高峰自动处理开关(简单开关)
22
+ offPeakAutoResume: true, // 低谷自动恢复开关:低峰时段自动恢复被高峰暂停的会话
23
+ weekendMode: true, // 周末模式开关:识别周末,无视峰谷(简单开关)
24
+ timezone: 'Asia/Shanghai',
25
+ peakWindows: [
26
+ { start: '09:00', end: '12:00' },
27
+ { start: '14:00', end: '18:00' },
28
+ ],
29
+ pauseMode: 'safe', // 透传 taskControl.pause mode
30
+ pauseReason: 'wait', // 透传 taskControl.pause reason
31
+ queueFallback: true, // 无会话门时回退锁等待队列(简单开关)
32
+ retryEnabled: false, // 自动重试开关(后端,D9;默认关,保守)
33
+ retryText: DEFAULT_RETRY.retryText,
34
+ retryGraceMs: DEFAULT_RETRY.retryGraceMs,
35
+ retryCooldownMs: DEFAULT_RETRY.retryCooldownMs,
36
+ retryBackoffFactor: DEFAULT_RETRY.retryBackoffFactor,
37
+ retryBackoffMaxMs: DEFAULT_RETRY.retryBackoffMaxMs,
38
+ retryMaxConsecutive: DEFAULT_RETRY.retryMaxConsecutive,
39
+ })
40
+
41
+ /**
42
+ * 设置 schema(schemastery 原生命令,zod 变体):
43
+ * - 枚举用 z.union([z.const(...)]);默认值用字段级 .default()。
44
+ */
45
+ export const SettingsSchema = z.object({
46
+ enabled: z.boolean().default(DEFAULT_SETTINGS.enabled),
47
+ offPeakAutoResume: z.boolean().default(DEFAULT_SETTINGS.offPeakAutoResume),
48
+ weekendMode: z.boolean().default(DEFAULT_SETTINGS.weekendMode),
49
+ timezone: z.string().default(DEFAULT_SETTINGS.timezone),
50
+ peakWindows: z
51
+ .array(z.object({ start: z.string(), end: z.string() }))
52
+ .default(DEFAULT_SETTINGS.peakWindows),
53
+ pauseMode: z.union([z.const('safe'), z.const('force')]).default(DEFAULT_SETTINGS.pauseMode),
54
+ pauseReason: z.union([z.const('wait'), z.const('stop')]).default(DEFAULT_SETTINGS.pauseReason),
55
+ queueFallback: z.boolean().default(DEFAULT_SETTINGS.queueFallback),
56
+ retryEnabled: z.boolean().default(DEFAULT_SETTINGS.retryEnabled),
57
+ retryText: z.string().default(DEFAULT_SETTINGS.retryText),
58
+ retryGraceMs: z.number().min(0).default(DEFAULT_SETTINGS.retryGraceMs),
59
+ retryCooldownMs: z.number().min(0).default(DEFAULT_SETTINGS.retryCooldownMs),
60
+ retryBackoffFactor: z.number().min(1).default(DEFAULT_SETTINGS.retryBackoffFactor),
61
+ retryBackoffMaxMs: z.number().min(0).default(DEFAULT_SETTINGS.retryBackoffMaxMs),
62
+ retryMaxConsecutive: z.number().min(0).default(DEFAULT_SETTINGS.retryMaxConsecutive),
63
+ })
64
+
65
+ /**
66
+ * dsh settings 服务的最小本地接口(仿 dsh-thinking-levels 的 local face):
67
+ * 插件不 value-import `@deepseek-ai/dsh-settings`,只通过这些形状在注入面调用。
68
+ */
69
+
70
+ /** @typedef {{ get(): unknown; watch(cb: () => void): () => void }} SettingsScopeLike */
71
+ /** @typedef {{ register(ns: string, schema: unknown, options?: { base?: unknown }): SettingsScopeLike }} SettingsServiceLike */
72
+ /** @typedef {{ inject(deps: readonly string[], fn: (s: { settings: SettingsServiceLike; effect(cb: () => (() => void) | void, label?: string): void }) => void): void }} SettingsAwareCtx */
73
+
74
+ /**
75
+ * 注册 设置 → 插件 → session-guard 子板块(简单开关)。
76
+ * - 走 `settings` 注入面,`base` 层叠组合配置;对 runtime 调用方经 `ctx.settings.get(NS)` 读取。
77
+ * - 任何失败(settings 服务缺失 / 注入异常)→ 返回 false,静默降级用默认配置。
78
+ * @param {object} ctx - host context(应含 cordis `settings` 注入面)。
79
+ * @returns {boolean} 注册成功 true;设置服务不可用时 false(fail-open)。
80
+ */
81
+ export function registerSettings(ctx) {
82
+ try {
83
+ // 本插件顶层 `inject` 已声明 `settings`(见 src/index.js 的
84
+ // `export const inject`),所以 apply 时 `ctx.settings` 已是完整
85
+ // SettingsProvider(带 .register),直接注册即可 —— 无需再 `ctx.inject`
86
+ // 二次动态注入(对已在 fiber 上解析的服务做二次注入,回调作为异步插件
87
+ // apply 排队,`registerSettings` 同步返回 true 会掩盖实际操作未生效)。
88
+ // 与 dsh-thinking-levels / dsh-context 的区别仅在于它们顶层未声明
89
+ // settings,才必须动态注入;这里已声明,直接用最可靠。
90
+ const svc = /** @type {{ register(ns: string, schema: unknown, options?: { base?: unknown }): unknown }} */ (ctx.settings)
91
+ // base 用副本:DEFAULT_SETTINGS 被 Object.freeze,直接当 base 可能被写。
92
+ svc.register(NS, SettingsSchema, { base: { ...DEFAULT_SETTINGS } })
93
+ return true
94
+ } catch {
95
+ return false
96
+ }
97
+ }
package/src/store.js ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * dsh-session-guard — 每会话持久化状态存储(学 dsh-task-control 的独立存储模式,D4)。
3
+ *
4
+ * 暂停/锁定状态不写 session log(harness 持久化 reader 只认已知事件类型),
5
+ * 改为插件自有 JSON 文件:`$DSH_HOME/.dsh/session-guard/<sessionId>.json`
6
+ * (可用 DSH_SESSION_GUARD_STATE_DIR 覆盖根目录)。原子写:tmp + rename。
7
+ */
8
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
9
+ import { dirname, join } from 'node:path'
10
+
11
+ /** 状态根目录。 */
12
+ export function storeRoot() {
13
+ return (
14
+ process.env.DSH_SESSION_GUARD_STATE_DIR ||
15
+ join(process.env.DSH_HOME || process.env.HOME || process.cwd(), '.dsh', 'session-guard')
16
+ )
17
+ }
18
+
19
+ /** 会话 id → 安全文件名。 */
20
+ function encodeSessionId(id) {
21
+ return String(id).replace(/[^a-zA-Z0-9._-]/g, '_')
22
+ }
23
+
24
+ /** 每会话状态文件路径。 */
25
+ export function stateFilePath(id) {
26
+ return join(storeRoot(), `${encodeSessionId(id)}.json`)
27
+ }
28
+
29
+ /** 未锁定基线。 */
30
+ export function idleState(id) {
31
+ return { sessionId: String(id), queueLocked: false, lockReason: null, updatedAt: null }
32
+ }
33
+
34
+ /**
35
+ * 创建存储(内存缓存 + 磁盘持久化)。
36
+ */
37
+ export function createStore() {
38
+ const cache = new Map()
39
+ return {
40
+ /** 读一会话状态;无记录返回 null(调用方自行 fallback idleState)。 */
41
+ get(id) {
42
+ if (cache.has(id)) return cache.get(id)
43
+ try {
44
+ const f = stateFilePath(id)
45
+ if (!existsSync(f)) return null
46
+ const v = JSON.parse(readFileSync(f, 'utf8'))
47
+ cache.set(id, v)
48
+ return v
49
+ } catch {
50
+ return null
51
+ }
52
+ },
53
+ /** 原子写。 */
54
+ set(id, value) {
55
+ cache.set(id, value)
56
+ try {
57
+ const f = stateFilePath(id)
58
+ mkdirSync(dirname(f), { recursive: true })
59
+ const tmp = `${f}.tmp`
60
+ writeFileSync(tmp, JSON.stringify(value, null, 2))
61
+ renameSync(tmp, f)
62
+ } catch (e) {
63
+ // 落盘失败仅影响重启恢复,内存态仍可用。
64
+ console.error(`[session-guard] store write failed: ${String(e && e.message || e)}`)
65
+ }
66
+ },
67
+ /** 清除(解锁时)。 */
68
+ clear(id) {
69
+ cache.delete(id)
70
+ try {
71
+ rmSync(stateFilePath(id), { force: true })
72
+ } catch {
73
+ /* ignore */
74
+ }
75
+ },
76
+ }
77
+ }
package/src/time.js ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * dsh-session-guard — 时间判定(纯函数,零依赖,可单测)。
3
+ *
4
+ * 高峰/周末识别必须基于「配置时区」的墙钟,而不是裸 UTC(D1):
5
+ * 周末判断用 `Intl.DateTimeFormat(timeZone)` 投影,避免 off-peak 那种
6
+ * 用 `getUTCDay()` 导致北京周末边界错 8 小时的 bug。
7
+ *
8
+ * 高峰时段为左闭右开 [start, end);跨午夜窗口(start > end)安全。
9
+ */
10
+
11
+ /** 解析 "HH:mm" → 当日分钟数,非法返回 null。 */
12
+ export function parseHHMM(s) {
13
+ const m = /^(\d{1,2}):(\d{2})$/.exec(String(s).trim())
14
+ if (!m) return null
15
+ const h = Number(m[1])
16
+ const mi = Number(m[2])
17
+ if (h > 23 || mi > 59) return null
18
+ return h * 60 + mi
19
+ }
20
+
21
+ /** 闭开区间 [s, e),跨午夜安全(s > e 时视为跨天环绕)。 */
22
+ export function inWindow(t, s, e) {
23
+ if (s === e) return false
24
+ return s < e ? t >= s && t < e : t >= s || t < e
25
+ }
26
+
27
+ /**
28
+ * 把某时刻投影为配置时区(IANA,DST 感知)的墙钟分量。
29
+ * @param {string} tz IANA 时区名,如 "Asia/Shanghai"
30
+ * @param {Date} date
31
+ * @returns {{year:number,month:number,day:number,weekday:number,minutes:number}}
32
+ * weekday: 0=周日 ... 6=周六(与 getUTCDay 同约定,但基于配置时区的日期)
33
+ */
34
+ export function wallClock(tz, date) {
35
+ const f = new Intl.DateTimeFormat('en-US', {
36
+ timeZone: tz,
37
+ hour12: false,
38
+ year: 'numeric',
39
+ month: '2-digit',
40
+ day: '2-digit',
41
+ hour: '2-digit',
42
+ minute: '2-digit',
43
+ })
44
+ const parts = {}
45
+ for (const p of f.formatToParts(date)) parts[p.type] = p.value
46
+ const year = Number(parts.year)
47
+ const month = Number(parts.month)
48
+ const day = Number(parts.day)
49
+ // 由该时区的「今天」日期反推 weekday,避免 UTC 边界错位(D1)。
50
+ const weekday = new Date(Date.UTC(year, month - 1, day)).getUTCDay()
51
+ const minutes = Number(parts.hour) * 60 + Number(parts.minute)
52
+ return { year, month, day, weekday, minutes }
53
+ }
54
+
55
+ /** 周末(0=周日, 6=周六)。 */
56
+ export function isWeekend(weekday) {
57
+ return weekday === 0 || weekday === 6
58
+ }
59
+
60
+ /** 某墙钟分钟是否处于任一高峰窗口内。 */
61
+ export function isInPeak(wc, windows) {
62
+ return windows.some((w) => {
63
+ const s = parseHHMM(w && w.start)
64
+ const e = parseHHMM(w && w.end)
65
+ if (s === null || e === null) return false
66
+ return inWindow(wc.minutes, s, e)
67
+ })
68
+ }
69
+
70
+ /**
71
+ * 主判定:此刻是否应触发高峰暂停。
72
+ * @param {object} settings { enabled, weekendMode, timezone, peakWindows }
73
+ * @param {Date} date
74
+ * @returns {{pause:boolean, reason:string}}
75
+ * reason: 'disabled' | 'weekend' | 'peak' | 'off-peak'
76
+ */
77
+ export function shouldPause(settings, date) {
78
+ if (!settings || settings.enabled !== true) return { pause: false, reason: 'disabled' }
79
+ const wc = wallClock(settings.timezone, date)
80
+ // 周末模式:识别周末 → 无视峰谷,畅快跑(D6)。
81
+ if (settings.weekendMode === true && isWeekend(wc.weekday)) {
82
+ return { pause: false, reason: 'weekend' }
83
+ }
84
+ if (isInPeak(wc, settings.peakWindows || [])) {
85
+ return { pause: true, reason: 'peak' }
86
+ }
87
+ return { pause: false, reason: 'off-peak' }
88
+ }