dsh-llm-fallbacks 0.3.3 → 0.3.5

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/README.md CHANGED
@@ -2,11 +2,12 @@
2
2
 
3
3
  [English](README.md) | [中文](README.zh-CN.md)
4
4
 
5
+ [![npm](https://img.shields.io/npm/dt/dsh-llm-fallbacks)](https://www.npmjs.com/package/dsh-llm-fallbacks)
5
6
  [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
7
  ![node](https://img.shields.io/badge/node-%3E%3D22-339933.svg)
7
8
  ![pnpm](https://img.shields.io/badge/pnpm-%3E%3D10-f69220.svg)
8
9
  ![dsh tui](https://img.shields.io/badge/dsh%20tui-compatible-4B32C3.svg)
9
- ![dsh](https://img.shields.io/badge/DSH-0.1.1--rc.1-4B32C3.svg)
10
+ ![dsh](https://img.shields.io/badge/DSH-0.1.1--rc.2-4B32C3.svg)
10
11
  [![dshfind](https://dshfind.com/api/badge/omdsh-dev/dsh-llm-fallbacks?lang=en)](https://dshfind.com/zh/plugins/omdsh-dev/dsh-llm-fallbacks?ref=badge)
11
12
 
12
13
  Automatic provider/model fallback chains for dsh (DeepSeek Harness): when an agent's LLM requests keep failing — retries exhausted, auth errors, quota exceeded, rate limiting (429) — the plugin switches provider/model along the fallback chain for the current role, and the current step/turn continues on the target model: tasks are not interrupted by model problems.
@@ -23,7 +24,7 @@ Four frozen UTC+8 presets (windows are code constants; preset rows lock `tz` to
23
24
 
24
25
  | Preset | Window |
25
26
  |---|---|
26
- | `liang-peak` | 09:00–12:00 and 14:00–18:00, every day |
27
+ | `liang-peak` | Monday–Friday 09:00–12:00 and 14:00–18:00 |
27
28
  | `liang-valley` | every other UTC+8 time (complement of Liang Peak) |
28
29
  | `glm-peak` | Monday–Friday 14:00–18:00 |
29
30
  | `glm-valley` | every other time (complement of GLM Peak) |
@@ -81,7 +82,7 @@ fallbacks:
81
82
  - deepseek-official/deepseek-v4-flash # last resort (Flash or Pro)
82
83
  timeSlots: # optional: rotate the effective root chain by wall-clock windows
83
84
  - kind: preset # frozen UTC+8 window; only the chain is editable
84
- preset: liang-peak # 09:00–12:00 and 14:00–18:00, every day
85
+ preset: liang-peak # Monday–Friday 09:00–12:00 and 14:00–18:00
85
86
  chain:
86
87
  - anthropic/claude-3-5-sonnet
87
88
  - kind: custom # custom window (may wrap midnight)
@@ -130,6 +131,7 @@ Save the config and restart the session, then type `/fallbacks` — the read-onl
130
131
  - **Time slots**: optional `fallbacks.timeSlots` rows rotate the effective root chain by wall-clock windows in the config-level `tz` timezone (default `Asia/Shanghai`) — four frozen UTC+8 presets (`liang-peak` / `liang-valley` / `glm-peak` / `glm-valley`, windows are code constants, models-only edits) or custom `start`/`end`/`days` windows. The first matching row wins; the all-day row is always last. A slot change applies on the **next** root request and is logged as a **time-slot switch** — a routing seed, never a failure decision: it consumes no cooldown and does not count against `maxSwitchesPerStep`. Failure walks keep the **fallback switch** copy (see [Time-slot presets](#time-slot-presets)).
131
132
  - **Dispatch-time role resolution**: on a subagent's first request its role is resolved in three stages — explicit (`agentPreset` matches a declared role id) → deterministic rules (unchanged) → LLM auto-match from the declared role taxonomy (`fallbacks.roleAutoMatch`, default `true`). The resolved role's chain-head model is injected into the first request and recorded via an explicit `role → model` log line (no durable `fallbacks/switch` event is written — issue #52 stop-write); set `roleAutoMatch: false` to disable the LLM auto-match stage (the explicit `agentPreset` stage still applies — with no explicit role this reproduces the previous rules-only behavior). The settings card always renders an **Enable role auto-match** switch (default `true`) to toggle it — the schema default applies even to legacy configs that never declared the key.
132
133
  - **Cooldown and revert**: failed / switched-away models are not re-selected during cooldown; `revertPolicy: cooldown-expiry` returns to the primary model automatically.
134
+ - **Half-open recovery (opt-in)**: `recovery: half-open` makes recovery evidence-driven — an expired cooldown leaves the route half-open for one logged probe instead of restoring the preference; consecutive failures escalate the suppression duration (×2 per failure, capped at 1 h); an observed completion closes the circuit and fully restores the preference. `revertPolicy: 'never'` keeps the mechanism inert; state is session-scoped in-memory (a restart resets). YAML-only — the default `timer` keeps every existing behavior byte-identical (see [docs/configuration.md](docs/configuration.md#recovery-mode-recovery-key)).
133
135
  - **Visible behavior**: every switch is recorded in an info-level log line (from/to/role/reason) — no silent model switching. The plugin deliberately writes **no** durable `fallbacks/switch` session events (issue #52: the apply()-time event-type registration was proven ineffective, and a session containing the event refused to load after a dsh restart). Sessions written by older plugin versions that contain such events are repaired by `scripts/repair-fallbacks-switch-logs.ts`, which marks legacy events ignorable so affected sessions load again.
134
136
  - **Safety valves**: `maxSwitchesPerStep` caps switches per step and `alwaysModeRetryCap` caps always-mode retries — chain loops cannot amplify latency.
135
137
  - **No-config no-op**: with no chains configured the plugin behaves exactly like not being installed (`enabled` is off by default — see [Minimal configuration](#minimal-configuration)).
@@ -138,7 +140,7 @@ Save the config and restart the session, then type `/fallbacks` — the read-onl
138
140
 
139
141
  In a dsh-tui profile the plugin has three operator surfaces, with a strict duty split:
140
142
 
141
- - **`/fallbacks`** — what happened this session: origin, resolved role, effective chain, recent fallback switches, cooldown status. Read-only.
143
+ - **`/fallbacks`** — what happened this session: origin, resolved role, effective chain, recent fallback switches, cooldown status (half-open marker rows when `recovery: half-open` is active). Read-only.
142
144
  - **`/fallbacks config`** — what is configured: composed-config readback (trigger codes, root chain, time slots, timezone, roles, role rules, cooldown, revert policy, safety valves, presets, role auto-match). Read-only apart from the one action command **`/fallbacks config revert-seed <role-id>`**, which restores a seeded role's persona to its declared seed default (a web-card action the settings seam cannot express).
143
145
  - **`/settings`** — the edit surface. The plugin registers a **fallbacks** section with full parity to the web settings card: booleans (`enabled`, `roleAutoMatch`) render as toggles, selects (`presets`, `revertPolicy`) as pickers, and numbers (`cooldownMs`, `maxSwitchesPerStep`, `alwaysModeRetryCap`) as numeric inputs; complex structures (`rootChain`, `timeSlots`, `roles.list`, `roles.rules`) are JSON text fields and `triggerCodes` a comma-separated text field. Invalid drafts (bad JSON, non-conforming chains, malformed time-slot rows) block the save — the section never corrupts the config.
144
146
 
@@ -167,7 +169,7 @@ Notes:
167
169
  Time slots are introduced in the [featured overview](#time-slots) above; this section is the reference. Time-slot rows rotate the **effective root chain** by wall-clock windows — useful for peak/valley pricing without confusing wall-clock rotation with failure fallback. The copy split is strict: slot rotation logs and UI say **time-slot switch**; the failure walk keeps **fallback switch**; the conversation notice Model downgraded stays on the failure path only.
168
170
 
169
171
  - **Match order**: at every root request, the first extra row whose window contains the current moment (in `fallbacks.tz`, default `Asia/Shanghai` / UTC+8) wins — that row's chain **replaces** the all-day chain. No row matches → the all-day `rootChain` is used. The all-day row is always last and **required**: its last entry must be exactly one official V4 model (Flash XOR Pro; leading Default fallback chain entries are walked first).
170
- - **Presets** (frozen, not user-editable): `liang-peak` = 09:00–12:00 **and** 14:00–18:00 every day; `liang-valley` = every other UTC+8 time; `glm-peak` = Monday–Friday 14:00–18:00; `glm-valley` = every other time. One preset id = one row; the card picker never offers a duplicate.
172
+ - **Presets** (frozen, not user-editable): `liang-peak` = Monday–Friday 09:00–12:00 **and** 14:00–18:00; `liang-valley` = every other UTC+8 time; `glm-peak` = Monday–Friday 14:00–18:00; `glm-valley` = every other time. One preset id = one row; the card picker never offers a duplicate.
171
173
  - **Custom rows**: `start` / `end` (`HH:mm`, may wrap midnight) + optional `days` (0=Sunday…6=Saturday; omitted/empty = every day) + models.
172
174
  - **Next-request apply**: a slot boundary crossing never preempts an in-flight step — the new row takes effect on the next root request. Rotation is mount-only: info log + card/`/fallbacks` status line, no durable switch event.
173
175
  - **Settings card**: the Main agent section groups Time slots (extra rows — add preset / add custom / remove / reorder by buttons or **drag**; preset rows show a read-only window summary and edit models only; custom rows carry an editable name; the **timezone picker** lives here and **locks to Asia/Shanghai while any preset row exists**, since preset windows are frozen UTC+8 constants), Default fallback chain (walked first when no slot matches) and Default model (the official V4 Flash | Pro last-resort fallback). Rows are collapsible to name + first model. There is no `timeSlots.enabled` master switch (adding a row is the opt-in) and no `rootMode` control.
package/README.zh-CN.md CHANGED
@@ -2,11 +2,12 @@
2
2
 
3
3
  [English](README.md) | [中文](README.zh-CN.md)
4
4
 
5
+ [![npm](https://img.shields.io/npm/dt/dsh-llm-fallbacks)](https://www.npmjs.com/package/dsh-llm-fallbacks)
5
6
  [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
7
  ![node](https://img.shields.io/badge/node-%3E%3D22-339933.svg)
7
8
  ![pnpm](https://img.shields.io/badge/pnpm-%3E%3D10-f69220.svg)
8
9
  ![dsh tui](https://img.shields.io/badge/dsh%20tui-compatible-4B32C3.svg)
9
- ![dsh](https://img.shields.io/badge/DSH-0.1.1--rc.1-4B32C3.svg)
10
+ ![dsh](https://img.shields.io/badge/DSH-0.1.1--rc.2-4B32C3.svg)
10
11
  [![dshfind](https://dshfind.com/api/badge/omdsh-dev/dsh-llm-fallbacks?lang=zh)](https://dshfind.com/zh/plugins/omdsh-dev/dsh-llm-fallbacks?ref=badge)
11
12
 
12
13
  dsh(DeepSeek Harness)的自动模型降级插件:当 root agent 或 subagent 的模型请求持续失败(重试耗尽、权限、配额超限、限流 429)时,按角色/模型 fallback 链自动切换 provider/model,当前 step/turn 在目标模型上继续完成——任务不因模型问题中断。
@@ -23,7 +24,7 @@ dsh(DeepSeek Harness)的自动模型降级插件:当 root agent 或 subage
23
24
 
24
25
  | 预设 | 窗口 |
25
26
  |---|---|
26
- | `liang-peak` | 每天 09:00–12:00 与 14:00–18:00 |
27
+ | `liang-peak` | 周一至周五 09:00–12:00 与 14:00–18:00 |
27
28
  | `liang-valley` | 其它所有 UTC+8 时间(Liang Peak 的补集) |
28
29
  | `glm-peak` | 周一至周五 14:00–18:00 |
29
30
  | `glm-valley` | 其余时间(GLM Peak 的补集) |
@@ -81,7 +82,7 @@ fallbacks:
81
82
  - deepseek-official/deepseek-v4-flash # 最后一档(Flash 或 Pro)
82
83
  timeSlots: # 可选:按墙钟窗口轮换生效 root 链
83
84
  - kind: preset # 冻结的 UTC+8 窗口;仅链可编辑
84
- preset: liang-peak # 每天 09:00–12:00 与 14:00–18:00
85
+ preset: liang-peak # 周一至周五 09:00–12:00 与 14:00–18:00
85
86
  chain:
86
87
  - anthropic/claude-3-5-sonnet
87
88
  - kind: custom # 自定义窗口(可跨午夜)
@@ -130,6 +131,7 @@ fallbacks:
130
131
  - **峰谷无忧(分时切换)**:可选的 `fallbacks.timeSlots` 行按墙钟窗口(配置级 `tz` 时区,默认 `Asia/Shanghai`)轮换 root 生效链——四个冻结的 UTC+8 预设(`liang-peak` / `liang-valley` / `glm-peak` / `glm-valley`,窗口为代码常量、仅模型链可编辑),或自定义 `start`/`end`/`days` 窗口。第一条命中的行生效;全时段行固定最后。时段切换在**下一个** root 请求生效,日志记为**分时切换**——路由种子而非失败决策:不消耗冷却、不计入 `maxSwitchesPerStep`。失败降级保留**降级切换**文案(见 [分时槽预设(分时切换)](#分时槽预设分时切换))。
131
132
  - **派发时角色解析**:在 subagent 的首次请求上,其角色按三个阶段解析——显式(`agentPreset` 匹配已声明角色 id)→ 确定性规则(不变)→ LLM 自动匹配(从已声明角色体系中选择,`fallbacks.roleAutoMatch` 默认 `true`)。解析出的角色的链头模型注入首次请求,并以显式 `role → model` 日志行记录(不写 durable `fallbacks/switch` 事件——issue #52 停写);设 `roleAutoMatch: false` 仅关闭 LLM 自动匹配阶段(显式 `agentPreset` 阶段仍生效——无显式角色时即复现原有仅规则行为)。设置卡总是渲染「启用角色自动匹配」开关(默认 `true`)以切换之——即使是从未声明过该键的旧配置,schema 默认值同样生效。
132
133
  - **冷却与回主**:被切离/失败的模型在冷却期内不再入选;`revertPolicy: cooldown-expiry` 冷却到期后自动回主模型。
134
+ - **半开恢复(可选)**:`recovery: half-open` 让恢复以证据驱动——冷却到期后路由进入 **half-open**,以一次记录探针(logged probe)放行,而不是直接恢复首选;连续失败使抑制时长按 **×2** 逐次升级、**1 小时**封顶;观察到完成即闭合回路、完全恢复首选。`revertPolicy: 'never'` 使该机制完全失效;状态为会话级内存态(重启即重置)。仅 YAML 配置——默认 `timer` 保持所有既有行为逐字节一致(见 [docs/configuration.md](docs/configuration.md#recovery-mode-recovery-key))。
133
135
  - **行为可见**:每次切换以 info 级日志行(from/to/role/reason)记录——无静默换模型。插件**刻意不写** durable `fallbacks/switch` 会话事件(issue #52——apply() 时的事件类型注册被证伪无效,含该事件的会话在 dsh 重启后拒绝加载)。由旧版插件写入、含此类事件的会话由 `scripts/repair-fallbacks-switch-logs.ts` 修复——旧事件被标记 ignorable 后,受影响会话可重新加载。
134
136
  - **安全阀**:`maxSwitchesPerStep` 限制每 step 切换次数、`alwaysModeRetryCap` 限制 always 模式重试——链循环不会放大延迟。
135
137
  - **无配置回归(no-op)**:未配置任何链时行为与未安装插件完全一致——`enabled` 默认关闭(见 [最小配置](#最小配置))。
@@ -138,7 +140,7 @@ fallbacks:
138
140
 
139
141
  在 dsh-tui profile 中,插件有三个操作面——职责严格区分:
140
142
 
141
- - **`/fallbacks`** —— 本次会话发生了什么:来源、解析角色、生效链、最近降级切换、冷却状态。只读。
143
+ - **`/fallbacks`** —— 本次会话发生了什么:来源、解析角色、生效链、最近降级切换、冷却状态(`recovery: half-open` 生效时显示 half-open 标记行)。只读。
142
144
  - **`/fallbacks config`** —— 配置了什么:组合配置回读(触发码、根链、分时槽、时区、角色、角色规则、冷却、回主策略、安全阀、预置、角色自动匹配)。除唯一的动作命令 **`/fallbacks config revert-seed <role-id>`** 外只读——该命令把某个 seed 角色的 persona 还原为已声明的默认(设置 seam 无法表达 Web 卡的这类动作能力)。
143
145
  - **`/settings`** —— 编辑界面。插件注册 **fallbacks** 区块,与 **Web 设置卡完全一致**:布尔(`enabled`、`roleAutoMatch`)渲染为开关、下拉(`presets`、`revertPolicy`)为选择器、数值(`cooldownMs`、`maxSwitchesPerStep`、`alwaysModeRetryCap`)为数字输入;复杂结构(`rootChain`、`timeSlots`、`roles.list`、`roles.rules`)为 JSON 文本字段,`triggerCodes` 为逗号分隔文本字段。非法草稿(JSON 解析失败、链尾不合规、分时行畸形)会阻止保存——区块绝不写入损坏配置。
144
146
 
@@ -167,7 +169,7 @@ fallbacks:
167
169
  峰谷无忧在[首页专题](#峰谷无忧)中介绍,本节是完整参考。分时槽行按墙钟窗口轮换**生效 root 链**——适合按峰谷切换模型,且不会把墙钟轮换误认为故障降级。文案严格区分:时段轮换的日志与 UI 用**分时切换**;失败降级保持**降级切换**;会话内「模型已降级」提示只出现在失败路径。
168
170
 
169
171
  - **匹配顺序**:每个 root 请求时刻,第一条窗口包含当前时刻(按 `fallbacks.tz`,默认 `Asia/Shanghai` / UTC+8)的额外行生效——该行的模型链**取代**全时段链;无行命中则用全时段 `rootChain`。全时段行固定最后且**必选**:最后一项必须是恰好一个官方 V4 模型(Flash 或 Pro;前面的降级条目先走)。
170
- - **预设**(冻结,不可编辑窗口):`liang-peak` = 每天 09:00–12:00 **与** 14:00–18:00;`liang-valley` = 其它所有 UTC+8 时间;`glm-peak` = 周一至周五 14:00–18:00;`glm-valley` = 其余时间。一个预设 id 对应一行;设置卡的选择器不会重复提供已添加的预设。
172
+ - **预设**(冻结,不可编辑窗口):`liang-peak` = 周一至周五 09:00–12:00 **与** 14:00–18:00;`liang-valley` = 其它所有 UTC+8 时间;`glm-peak` = 周一至周五 14:00–18:00;`glm-valley` = 其余时间。一个预设 id 对应一行;设置卡的选择器不会重复提供已添加的预设。
171
173
  - **自定义行**:`start` / `end`(`HH:mm`,可跨午夜)+ 可选 `days`(0=周日…6=周六;缺省/空 = 每天)+ 模型。
172
174
  - **下一请求生效**:时段边界跨越绝不打断进行中的 step——新行在下一个 root 请求生效。轮换仅挂载生效:info 日志 + 设置卡/`/fallbacks` 状态行,无 durable 切换事件。
173
175
  - **设置卡**:主代理区块下分三块——**分时槽设置**(额外行:添加预设 / 添加自定义 / 删除 / 按钮或**拖拽**排序;预设行只读展示窗口摘要、仅可编辑模型链;自定义行带可编辑名称;**时区选择器**在此区块内,只要存在预设行就**锁定 Asia/Shanghai**——预设窗口是冻结的 UTC+8 常量)、**默认降级链**(all-day 链,可配置的 provider/model 选择器列表)与**默认模型**(官方 V4 Flash | Pro 二选一链头)。行可折叠为「名称 + 首个模型」。没有 `timeSlots.enabled` 总开关(添加行即开启),也没有 `rootMode` 控件。
@@ -199,18 +199,32 @@ window.__ModuleLoader__.load({
199
199
  };
200
200
  }
201
201
  /**
202
- * Frozen preset windows (UTC+8). `liang-*` presets have NO day mask (they
203
- * apply every day, weekends included); `glm-peak` is Monday–Friday only.
204
- * The two valleys are `complement: true` of their peak.
202
+ * Frozen preset windows (UTC+8). `liang-peak` and `glm-peak` are
203
+ * Monday–Friday only (day mask `[1, 2, 3, 4, 5]`); their valleys are the
204
+ * `complement: true` of the corresponding peak (weekends included).
205
205
  */
206
206
  const PRESETS = {
207
207
  "liang-peak": {
208
208
  windows: [{
209
209
  start: "09:00",
210
- end: "12:00"
210
+ end: "12:00",
211
+ days: [
212
+ 1,
213
+ 2,
214
+ 3,
215
+ 4,
216
+ 5
217
+ ]
211
218
  }, {
212
219
  start: "14:00",
213
- end: "18:00"
220
+ end: "18:00",
221
+ days: [
222
+ 1,
223
+ 2,
224
+ 3,
225
+ 4,
226
+ 5
227
+ ]
214
228
  }],
215
229
  complement: false,
216
230
  label: "Liang Peak"
@@ -218,10 +232,24 @@ window.__ModuleLoader__.load({
218
232
  "liang-valley": {
219
233
  windows: [{
220
234
  start: "09:00",
221
- end: "12:00"
235
+ end: "12:00",
236
+ days: [
237
+ 1,
238
+ 2,
239
+ 3,
240
+ 4,
241
+ 5
242
+ ]
222
243
  }, {
223
244
  start: "14:00",
224
- end: "18:00"
245
+ end: "18:00",
246
+ days: [
247
+ 1,
248
+ 2,
249
+ 3,
250
+ 4,
251
+ 5
252
+ ]
225
253
  }],
226
254
  complement: true,
227
255
  label: "Liang Valley"
@@ -458,7 +486,8 @@ window.__ModuleLoader__.load({
458
486
  presets: "bundled",
459
487
  roleAutoMatch: true,
460
488
  timeSlots: [],
461
- tz: "Asia/Shanghai"
489
+ tz: "Asia/Shanghai",
490
+ recovery: "timer"
462
491
  };
463
492
  /**
464
493
  * Reserved role id: legal as a rule target (`roles.rules[].role`) and as
@@ -615,6 +644,8 @@ window.__ModuleLoader__.load({
615
644
  }))) throw new TypeError("fallbacks descriptor timeSlots must be an array of slot rows (kind/preset/start/end strings, chain string array, days number array)");
616
645
  const tz = value.tz;
617
646
  if (tz !== void 0 && typeof tz !== "string") throw new TypeError("fallbacks descriptor tz must be a string");
647
+ const recovery = value.recovery;
648
+ if (recovery !== void 0 && recovery !== "timer" && recovery !== "half-open") throw new TypeError("fallbacks descriptor recovery must be timer|half-open");
618
649
  return {
619
650
  enabled: enabled ?? defaultFallbacksConfig.enabled,
620
651
  triggerCodes: triggerCodes ?? [...defaultFallbacksConfig.triggerCodes],
@@ -630,7 +661,8 @@ window.__ModuleLoader__.load({
630
661
  presets: presets ?? defaultFallbacksConfig.presets,
631
662
  roleAutoMatch: roleAutoMatch ?? defaultFallbacksConfig.roleAutoMatch,
632
663
  timeSlots: timeSlots ?? [...defaultFallbacksConfig.timeSlots ?? []],
633
- tz: tz ?? defaultFallbacksConfig.tz
664
+ tz: tz ?? defaultFallbacksConfig.tz,
665
+ recovery: recovery ?? defaultFallbacksConfig.recovery
634
666
  };
635
667
  }
636
668
  /** The raw selector string a selection serializes to ('' when empty). */
@@ -1317,9 +1349,9 @@ window.__ModuleLoader__.load({
1317
1349
  "timeSlots.preset.windowLabel": "时段(只读)",
1318
1350
  "timeSlots.preset.chainsOnly": "预设窗口已锁定:仅可编辑模型链",
1319
1351
  "timeSlots.preset.liang-peak.label": "梁文峰",
1320
- "timeSlots.preset.liang-peak.window": "09:00–12:00 与 14:00–18:00(每天,UTC+8)",
1352
+ "timeSlots.preset.liang-peak.window": "09:00–12:00 与 14:00–18:00(周一至周五,UTC+8)",
1321
1353
  "timeSlots.preset.liang-valley.label": "梁文谷",
1322
- "timeSlots.preset.liang-valley.window": "Liang Peak 之外的所有时间(每天,UTC+8)",
1354
+ "timeSlots.preset.liang-valley.window": "Liang Peak 之外的所有时间(UTC+8)",
1323
1355
  "timeSlots.preset.glm-peak.label": "GLM峰",
1324
1356
  "timeSlots.preset.glm-peak.window": "周一至周五 14:00–18:00(UTC+8)",
1325
1357
  "timeSlots.preset.glm-valley.label": "GLM谷",
@@ -1502,9 +1534,9 @@ window.__ModuleLoader__.load({
1502
1534
  "timeSlots.preset.windowLabel": "Window (read-only)",
1503
1535
  "timeSlots.preset.chainsOnly": "Preset windows are frozen: only the model chain is editable",
1504
1536
  "timeSlots.preset.liang-peak.label": "Liang Peak",
1505
- "timeSlots.preset.liang-peak.window": "09:00–12:00 & 14:00–18:00 (every day, UTC+8)",
1537
+ "timeSlots.preset.liang-peak.window": "09:00–12:00 & 14:00–18:00 (Monday–Friday, UTC+8)",
1506
1538
  "timeSlots.preset.liang-valley.label": "Liang Valley",
1507
- "timeSlots.preset.liang-valley.window": "All times outside Liang Peak (every day, UTC+8)",
1539
+ "timeSlots.preset.liang-valley.window": "All times outside Liang Peak (UTC+8)",
1508
1540
  "timeSlots.preset.glm-peak.label": "GLM Peak",
1509
1541
  "timeSlots.preset.glm-peak.window": "Monday–Friday 14:00–18:00 (UTC+8)",
1510
1542
  "timeSlots.preset.glm-valley.label": "GLM Valley",
@@ -57,6 +57,8 @@ export interface FallbacksCooldownEntry {
57
57
  readonly key: string;
58
58
  /** Expiry epoch ms; `Infinity` for `revertPolicy: 'never'`. */
59
59
  readonly untilEpochMs: number;
60
+ /** True for a half-open marker row (an expired cooldown awaiting a recovery probe). */
61
+ readonly halfOpen?: boolean;
60
62
  }
61
63
  /** The read-only diagnostic snapshot the `/fallbacks` handler renders. */
62
64
  export interface FallbacksCommandSnapshot {
@@ -192,6 +194,7 @@ export declare const FALLBACKS_COMMAND_LOCALES: {
192
194
  readonly cooldownNone: "无活跃冷却";
193
195
  readonly cooldownLine: "{key} 冷却至 {time}";
194
196
  readonly cooldownNever: "{key} 会话内不再回主";
197
+ readonly cooldownHalfOpen: "{key} half-open(等待恢复探针)";
195
198
  readonly reason: {
196
199
  readonly 'trigger-code': "触发码";
197
200
  readonly 'always-cap': "always 上限";
@@ -246,6 +249,7 @@ export declare const FALLBACKS_COMMAND_LOCALES: {
246
249
  readonly cooldownNone: "none active";
247
250
  readonly cooldownLine: "{key} suppressed until {time}";
248
251
  readonly cooldownNever: "{key} not reverting this session";
252
+ readonly cooldownHalfOpen: "{key} half-open (awaiting recovery probe)";
249
253
  readonly reason: {
250
254
  readonly 'trigger-code': "trigger-code";
251
255
  readonly 'always-cap': "always-cap";
package/dist/config.d.ts CHANGED
@@ -28,6 +28,8 @@
28
28
  * @module dsh-llm-fallbacks/config
29
29
  */
30
30
  import type { SlotRowConfig } from './time-slots.ts';
31
+ /** How an expired cooldown recovers the route (plan fallbacks-half-open-recovery Task 1). */
32
+ export type RecoveryPolicy = 'timer' | 'half-open';
31
33
  /** How a cooled-down model comes back (spec §4). */
32
34
  export type RevertPolicy = 'cooldown-expiry' | 'never';
33
35
  /** A single role rule: match on provider/model patterns (spec §3). */
@@ -125,6 +127,18 @@ export interface FallbacksConfig {
125
127
  * UTC+8). Not per-slot.
126
128
  */
127
129
  tz?: string;
130
+ /**
131
+ * Cooldown-expiry recovery mode (plan fallbacks-half-open-recovery Task
132
+ * 1): `'timer'` restores the preferred candidate when the cooldown
133
+ * expires (today's behavior); `'half-open'` leaves the route half-open
134
+ * for one logged probe instead. Optional on purpose, mirroring
135
+ * `presets` — a required field would break library consumers that
136
+ * construct `FallbacksConfig` literals with the existing keys
137
+ * (additive, non-breaking). The value domain is guarded by the schema
138
+ * (`Config` in `src/schema.ts`), NOT by `validateFallbacksConfig`, and
139
+ * every resolved config carries a value via the schema default.
140
+ */
141
+ recovery?: RecoveryPolicy;
128
142
  }
129
143
  /**
130
144
  * Spec §4 defaults — `Config({})` must equal this (no-op install).
@@ -24,6 +24,15 @@ export declare class CooldownStore {
24
24
  suppress(key: string, untilEpochMs: number): void;
25
25
  /** True while `key` is suppressed at time `now` (default `Date.now()`); expired entries are removed. */
26
26
  isSuppressed(key: string, now?: number): boolean;
27
+ /**
28
+ * Raw expiry read — no lazy drop (plan fallbacks-half-open-recovery P2
29
+ * rule 3). `undefined` for unknown keys; `Infinity` for
30
+ * `revertPolicy: 'never'` entries. The half-open read path uses this to
31
+ * observe the lapsed `until` before the decision-path drop.
32
+ */
33
+ peek(key: string): number | undefined;
34
+ /** Unfiltered map keys, in insertion order (plan P4 display sync). */
35
+ keys(): IterableIterator<string>;
27
36
  /**
28
37
  * Read-only diagnostic accessor: the active (non-expired) entries, in
29
38
  * insertion order. This is a pure read — expired entries are FILTERED OUT
package/dist/index.d.ts CHANGED
@@ -85,7 +85,7 @@ export type { FallbackSwitchReason, FallbacksSwitchEventData } from './events.ts
85
85
  export type { AgentFallbackState, FallbackStateStore, PendingSwitch, StepFailures } from './state.ts';
86
86
  export { resolveRole, type AgentLike, type Origin, } from './roles.ts';
87
87
  export { annotateCandidates, createCandidateFilter, hasWildcardEntry, resolveCandidate, resolveChain, resolveChainViews, selectCandidates, type AnnotatedCandidate, type CandidateFilterOptions, type CandidateSkipReason, type FailingModel, } from './chains.ts';
88
- export { defaultFallbacksConfig, detectLegacyKeys, INHERIT_ROLE_ID, ROLE_ID_PATTERN, validateFallbacksConfig, type FallbacksConfig, type FallbacksConfigLogger, type FallbacksRole, type FallbacksRoles, type FallbacksRoleRule, type FallbackStrategy, type RevertPolicy, } from './config.ts';
88
+ export { defaultFallbacksConfig, detectLegacyKeys, INHERIT_ROLE_ID, ROLE_ID_PATTERN, validateFallbacksConfig, type FallbacksConfig, type FallbacksConfigLogger, type FallbacksRole, type FallbacksRoles, type FallbacksRoleRule, type FallbackStrategy, type RevertPolicy, type RecoveryPolicy, } from './config.ts';
89
89
  export { parseSelector, SelectorError, type Selector, } from './selectors.ts';
90
90
  export { FallbacksSeedManager, type EffectiveRole, type EffectiveRolesReadback, type SeedConflict, type SeedDeclaration, type SeedDeclareOutcome, type SeedRevertFailReason, type SeedRevertOutcome, type SeedSkipReason, type SeedsIo, type SeedsWireStatus, } from './seeds.ts';
91
91
  export { presetRoles } from './presets.ts';
package/dist/index.js CHANGED
@@ -39,10 +39,24 @@ const PRESETS = {
39
39
  "liang-peak": {
40
40
  windows: [{
41
41
  start: "09:00",
42
- end: "12:00"
42
+ end: "12:00",
43
+ days: [
44
+ 1,
45
+ 2,
46
+ 3,
47
+ 4,
48
+ 5
49
+ ]
43
50
  }, {
44
51
  start: "14:00",
45
- end: "18:00"
52
+ end: "18:00",
53
+ days: [
54
+ 1,
55
+ 2,
56
+ 3,
57
+ 4,
58
+ 5
59
+ ]
46
60
  }],
47
61
  complement: false,
48
62
  label: "Liang Peak"
@@ -50,10 +64,24 @@ const PRESETS = {
50
64
  "liang-valley": {
51
65
  windows: [{
52
66
  start: "09:00",
53
- end: "12:00"
67
+ end: "12:00",
68
+ days: [
69
+ 1,
70
+ 2,
71
+ 3,
72
+ 4,
73
+ 5
74
+ ]
54
75
  }, {
55
76
  start: "14:00",
56
- end: "18:00"
77
+ end: "18:00",
78
+ days: [
79
+ 1,
80
+ 2,
81
+ 3,
82
+ 4,
83
+ 5
84
+ ]
57
85
  }],
58
86
  complement: true,
59
87
  label: "Liang Valley"
@@ -231,6 +259,65 @@ function resolveSlotState(config, now, tz) {
231
259
  label: "all-day"
232
260
  };
233
261
  }
262
+ const ESCALATION_CAP_MS = 36e5;
263
+ function escalatedCooldownMs(ms, n) {
264
+ return Math.max(ms, Math.min(ESCALATION_CAP_MS, ms * 2 ** (n - 1)));
265
+ }
266
+ var RecoveryStore = class {
267
+ entries = new Map();
268
+ recordFailure(key) {
269
+ const entry = this.entries.get(key);
270
+ if (entry === void 0) {
271
+ this.entries.set(key, {
272
+ consecutiveFailures: 1,
273
+ halfOpen: false,
274
+ probeLogged: false
275
+ });
276
+ return 1;
277
+ }
278
+ entry.consecutiveFailures += 1;
279
+ entry.halfOpen = false;
280
+ entry.probeLogged = false;
281
+ return entry.consecutiveFailures;
282
+ }
283
+ markHalfOpen(key, until) {
284
+ const entry = this.entries.get(key);
285
+ if (entry === void 0) {
286
+ this.entries.set(key, {
287
+ consecutiveFailures: 0,
288
+ halfOpen: true,
289
+ probeLogged: false,
290
+ expiredUntil: until
291
+ });
292
+ return;
293
+ }
294
+ entry.halfOpen = true;
295
+ entry.probeLogged = false;
296
+ entry.expiredUntil = until;
297
+ }
298
+ tryMarkProbeLogged(key) {
299
+ const entry = this.entries.get(key);
300
+ if (entry === void 0 || !entry.halfOpen || entry.probeLogged) return false;
301
+ entry.probeLogged = true;
302
+ return true;
303
+ }
304
+ isHalfOpen(key) {
305
+ return this.entries.get(key)?.halfOpen === true;
306
+ }
307
+ close(key) {
308
+ const entry = this.entries.get(key);
309
+ if (entry === void 0 || !entry.halfOpen) return;
310
+ this.entries.delete(key);
311
+ }
312
+ halfOpenEntries() {
313
+ const out = [];
314
+ for (const [key, entry] of this.entries) if (entry.halfOpen && entry.expiredUntil !== void 0) out.push({
315
+ key,
316
+ untilEpochMs: entry.expiredUntil
317
+ });
318
+ return out;
319
+ }
320
+ };
234
321
  //#endregion
235
322
  //#region .build/host/config.js
236
323
  const defaultFallbacksConfig = {
@@ -252,7 +339,8 @@ const defaultFallbacksConfig = {
252
339
  presets: "bundled",
253
340
  roleAutoMatch: true,
254
341
  timeSlots: [],
255
- tz: "Asia/Shanghai"
342
+ tz: "Asia/Shanghai",
343
+ recovery: "timer"
256
344
  };
257
345
  const INHERIT_ROLE_ID = "inherit";
258
346
  const ROLE_ID_PATTERN = /^[a-z0-9-]{1,32}$/;
@@ -297,6 +385,7 @@ function validateFallbacksConfig(config, logger) {
297
385
  if (row.start !== void 0 || row.end !== void 0 || row.days !== void 0 && row.days.length > 0) logger.warn(`llm-fallbacks: preset "${preset}" carries its own window/day fields — preset windows are fixed code constants; the row is ignored`);
298
386
  }
299
387
  }
388
+ if ((config.recovery ?? "timer") === "half-open" && config.cooldownMs >= 36e5) logger.warn(`llm-fallbacks: recovery "half-open" escalation is inert — cooldownMs (${config.cooldownMs} ms) is at or above the 1-hour escalation cap (${ESCALATION_CAP_MS} ms), so every suppression stays flat at cooldownMs`);
300
389
  }
301
390
  function detectLegacyKeys(source) {
302
391
  const keys = [];
@@ -366,7 +455,8 @@ const Config = z.object({
366
455
  days: z.array(z.number()),
367
456
  chain: z.array(z.string())
368
457
  })).default([]),
369
- tz: z.string().default("Asia/Shanghai")
458
+ tz: z.string().default("Asia/Shanghai"),
459
+ recovery: z.union([z.const("timer"), z.const("half-open")]).default("timer")
370
460
  });
371
461
  function buildAutomatchPrompt(roles, agent) {
372
462
  const lines = [
@@ -623,6 +713,12 @@ var CooldownStore = class {
623
713
  }
624
714
  return true;
625
715
  }
716
+ peek(key) {
717
+ return this.entries.get(key);
718
+ }
719
+ keys() {
720
+ return this.entries.keys();
721
+ }
626
722
  snapshot(now = Date.now()) {
627
723
  const active = [];
628
724
  for (const [key, until] of this.entries) {
@@ -673,7 +769,8 @@ var FallbackStateStore = class {
673
769
  failed: new StepFailureSet(),
674
770
  switchCount: 0
675
771
  },
676
- cooldown: new CooldownStore()
772
+ cooldown: new CooldownStore(),
773
+ recovery: new RecoveryStore()
677
774
  };
678
775
  this.states.set(agentId, state);
679
776
  }
@@ -724,8 +821,21 @@ var FallbackStateStore = class {
724
821
  suppress(state, key, untilEpochMs) {
725
822
  state.cooldown.suppress(key, untilEpochMs);
726
823
  }
727
- isSuppressed(state, key, now = Date.now()) {
728
- return state.cooldown.isSuppressed(key, now);
824
+ isSuppressed(state, key, now = Date.now(), recovery = "timer") {
825
+ if (recovery !== "half-open") return state.cooldown.isSuppressed(key, now);
826
+ const until = state.cooldown.peek(key);
827
+ if (until === void 0) return false;
828
+ if (until > now) return true;
829
+ state.cooldown.isSuppressed(key, now);
830
+ state.recovery.markHalfOpen(key, until);
831
+ return false;
832
+ }
833
+ syncRecovery(state, now, mode) {
834
+ if (mode !== "half-open") return;
835
+ for (const key of state.cooldown.keys()) this.isSuppressed(state, key, now, mode);
836
+ }
837
+ observeSuccess(state, key) {
838
+ state.recovery.close(key);
729
839
  }
730
840
  };
731
841
  //#endregion
@@ -743,7 +853,8 @@ const CONFIG_KEYS = {
743
853
  presets: true,
744
854
  roleAutoMatch: true,
745
855
  timeSlots: true,
746
- tz: true
856
+ tz: true,
857
+ recovery: true
747
858
  };
748
859
  const ROLES_KEYS = {
749
860
  list: true,
@@ -977,6 +1088,7 @@ const FALLBACKS_COMMAND_LOCALES = {
977
1088
  cooldownNone: "无活跃冷却",
978
1089
  cooldownLine: "{key} 冷却至 {time}",
979
1090
  cooldownNever: "{key} 会话内不再回主",
1091
+ cooldownHalfOpen: "{key} half-open(等待恢复探针)",
980
1092
  reason: {
981
1093
  "trigger-code": "触发码",
982
1094
  "always-cap": "always 上限"
@@ -1031,6 +1143,7 @@ const FALLBACKS_COMMAND_LOCALES = {
1031
1143
  cooldownNone: "none active",
1032
1144
  cooldownLine: "{key} suppressed until {time}",
1033
1145
  cooldownNever: "{key} not reverting this session",
1146
+ cooldownHalfOpen: "{key} half-open (awaiting recovery probe)",
1034
1147
  reason: {
1035
1148
  "trigger-code": "trigger-code",
1036
1149
  "always-cap": "always-cap"
@@ -1123,6 +1236,7 @@ function formatSwitch(entry, t) {
1123
1236
  return t.switchLine.replace("{from}", from).replace("{to}", to).replace("{role}", entry.role).replace("{reason}", t.reason[entry.reason] ?? entry.reason);
1124
1237
  }
1125
1238
  function formatCooldown(entry, t) {
1239
+ if (entry.halfOpen === true) return t.cooldownHalfOpen.replace("{key}", entry.key);
1126
1240
  if (!Number.isFinite(entry.untilEpochMs)) return t.cooldownNever.replace("{key}", entry.key);
1127
1241
  return t.cooldownLine.replace("{key}", entry.key).replace("{time}", new Date(entry.untilEpochMs).toISOString());
1128
1242
  }
@@ -2120,7 +2234,7 @@ function apply(ctx, config = defaultFallbacksConfig) {
2120
2234
  const { all, wildcard } = resolveChainViews(config.roles.list, rootTail, role, current.provider, current.model, logger.warn);
2121
2235
  if (all.length === 0) return null;
2122
2236
  const modelExists = hasWildcardEntry(config.roles.list, rootTail, role) ? await makeModelExists(ctx, [...new Set(all.map((candidate) => candidate.provider))]) : void 0;
2123
- const cooldown = { isSuppressed: (key) => state !== void 0 && states.isSuppressed(state, key) };
2237
+ const cooldown = { isSuppressed: (key) => state !== void 0 && states.isSuppressed(state, key, Date.now(), source().recovery ?? "timer") };
2124
2238
  const failed = { has: (key) => state !== void 0 && state.stepFailures.failed.has(key) };
2125
2239
  const surviving = selectCandidates(all, wildcard, createCandidateFilter({
2126
2240
  current,
@@ -2129,6 +2243,7 @@ function apply(ctx, config = defaultFallbacksConfig) {
2129
2243
  }), modelExists);
2130
2244
  const target = surviving[0];
2131
2245
  if (target === void 0 || target.model === void 0) return null;
2246
+ if ((source().recovery ?? "timer") === "half-open" && state !== void 0 && state.recovery.tryMarkProbeLogged(selectorKey(target.provider, target.model))) logger.info("llm-fallbacks: agent \"%s\" half-open probe %s/%s (role=%s, reason=%s)", agent.id, target.provider, target.model, role, reason);
2132
2247
  logger.info("llm-fallbacks: agent \"%s\" fallback switch (降级切换) %s/%s -> %s/%s (role=%s, reason=%s, candidates=%o)", agent.id, current.provider, current.model, target.provider, target.model, role, reason, annotateCandidates(all, surviving, {
2133
2248
  current,
2134
2249
  cooldown,
@@ -2150,13 +2265,22 @@ function apply(ctx, config = defaultFallbacksConfig) {
2150
2265
  function commit(state, pending, turn, step) {
2151
2266
  const config = source();
2152
2267
  const fromKey = selectorKey(pending.from.provider, pending.from.model);
2153
- const until = config.revertPolicy === "never" ? Number.POSITIVE_INFINITY : Date.now() + config.cooldownMs;
2268
+ const until = config.revertPolicy === "never" ? Number.POSITIVE_INFINITY : config.recovery === "half-open" ? Date.now() + escalatedCooldownMs(config.cooldownMs, state.recovery.recordFailure(fromKey)) : Date.now() + config.cooldownMs;
2154
2269
  states.syncStep(state, turn, step);
2155
2270
  states.writePending(state, pending);
2156
2271
  states.suppress(state, fromKey, until);
2157
2272
  states.recordFailure(state, fromKey);
2158
2273
  states.recordSwitch(state);
2159
2274
  }
2275
+ function failHalfOpenProbe(agentId, current) {
2276
+ const key = selectorKey(current.provider, current.model);
2277
+ if ((source().recovery ?? "timer") !== "half-open") return;
2278
+ if (source().revertPolicy === "never") return;
2279
+ if (states.peek(agentId)?.recovery.isHalfOpen(key) !== true) return;
2280
+ const state = states.get(agentId);
2281
+ const n = state.recovery.recordFailure(key);
2282
+ states.suppress(state, key, Date.now() + escalatedCooldownMs(source().cooldownMs, n));
2283
+ }
2160
2284
  ctx.on("agent/request-error", async ({ agent, turn, step, provider, failure }, next) => {
2161
2285
  const config = source();
2162
2286
  if (!config.enabled || !config.triggerCodes.includes(failure.code)) return next();
@@ -2164,7 +2288,10 @@ function apply(ctx, config = defaultFallbacksConfig) {
2164
2288
  if (!current.model) return next();
2165
2289
  try {
2166
2290
  const pending = await decide(agent, turn, step, current, "trigger-code", states.peek(agent.id));
2167
- if (pending === null) return next();
2291
+ if (pending === null) {
2292
+ failHalfOpenProbe(agent.id, current);
2293
+ return next();
2294
+ }
2168
2295
  commit(states.get(agent.id), pending, turn, step);
2169
2296
  return { kind: "retry" };
2170
2297
  } catch (error) {
@@ -2234,7 +2361,10 @@ function apply(ctx, config = defaultFallbacksConfig) {
2234
2361
  commit(commitState, pending, turn, step);
2235
2362
  const appliedCap = states.applyPending(commitState, turn, step);
2236
2363
  if (appliedCap !== void 0) return overrideConfig(seed, appliedCap.to);
2237
- }
2364
+ } else failHalfOpenProbe(agent.id, {
2365
+ provider: seed.provider,
2366
+ model: seed.model
2367
+ });
2238
2368
  }
2239
2369
  return seed;
2240
2370
  });
@@ -2248,6 +2378,16 @@ function apply(ctx, config = defaultFallbacksConfig) {
2248
2378
  dispatchInjected.delete(agent.id);
2249
2379
  slotWinners.delete(agent.id);
2250
2380
  });
2381
+ ctx.on("session/event", (session, event) => {
2382
+ if (event.type !== "assistant/message") return;
2383
+ if ((source().recovery ?? "timer") !== "half-open") return;
2384
+ if (event.data.interrupted === true) return;
2385
+ const message = event.data.message;
2386
+ if (message.source.kind !== "model") return;
2387
+ const state = states.peek(session.id);
2388
+ if (state === void 0) return;
2389
+ states.observeSuccess(state, selectorKey(message.source.provider, message.source.model));
2390
+ });
2251
2391
  ctx.effect(() => () => {
2252
2392
  states.clear();
2253
2393
  dispatchInjected.clear();
@@ -2258,13 +2398,19 @@ function apply(ctx, config = defaultFallbacksConfig) {
2258
2398
  const config = source();
2259
2399
  const role = resolveRole(agent, config.roles.rules, roleIds, logger.warn);
2260
2400
  const state = states.peek(agent.id);
2401
+ const recovery = config.recovery ?? "timer";
2402
+ if (state !== void 0 && recovery === "half-open") states.syncRecovery(state, Date.now(), recovery);
2261
2403
  return {
2262
2404
  origin: agent.session.header?.origin ?? "root",
2263
2405
  role,
2264
2406
  ...resolveChainForDiagnostic(config.roles.list, config.rootChain, role, logger.warn),
2265
2407
  slot: resolveSlotState(config, new Date(), config.tz ?? "Asia/Shanghai"),
2266
2408
  switches: recentFallbacksSwitches(agent.session.events, 5),
2267
- cooldown: state === void 0 ? [] : state.cooldown.snapshot()
2409
+ cooldown: state === void 0 ? [] : recovery === "half-open" ? [...state.cooldown.snapshot(), ...state.recovery.halfOpenEntries().map((entry) => ({
2410
+ key: entry.key,
2411
+ untilEpochMs: entry.untilEpochMs,
2412
+ halfOpen: true
2413
+ }))] : state.cooldown.snapshot()
2268
2414
  };
2269
2415
  },
2270
2416
  getConfig() {
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Half-open recovery state machine (plan fallbacks-half-open-recovery Task 2).
3
+ *
4
+ * `RecoveryStore` tracks per-route consecutive-failure counters and half-open
5
+ * episode flags, keyed `${provider}/${model}` via `selectorKey`. It is held
6
+ * per-agent on `AgentFallbackState` and is session-scoped in-memory (same
7
+ * lifetime as the cooldown store; a restart resets every route to a flat
8
+ * first cooldown).
9
+ *
10
+ * The store is pure logic — zero imports, the cooldown.ts purity precedent —
11
+ * so it is unit-testable without a host (recovery.spec.ts imports this module
12
+ * directly).
13
+ *
14
+ * State machine (plan P2):
15
+ * 1. `recordFailure` — a suppression write: increments the consecutive-failure
16
+ * counter and clears any half-open episode (rule 1).
17
+ * 2. `escalatedCooldownMs` — bounded geometric escalation of the suppression
18
+ * duration (rule 2).
19
+ * 3. `markHalfOpen` — an expired cooldown leaves the route half-open for one
20
+ * logged probe (rule 3).
21
+ * 4. `tryMarkProbeLogged` — the once-per-episode admission marker, not a gate
22
+ * (rule 4).
23
+ * 5. `isHalfOpen` — probe-failure gate for the no-surviving-target path
24
+ * (rule 5b).
25
+ * 6. `close` — an observed completion closes the circuit, only from half-open
26
+ * (rule 6).
27
+ *
28
+ * @module dsh-llm-fallbacks/recovery
29
+ */
30
+ /** Escalation multiplier applied per consecutive failure (rule 2). */
31
+ export declare const ESCALATION_MULTIPLIER = 2;
32
+ /** Escalation ceiling: 1 hour (rule 2). */
33
+ export declare const ESCALATION_CAP_MS = 3600000;
34
+ /**
35
+ * Bounded geometric escalation of the suppression duration (rule 2):
36
+ * `max(ms, min(ESCALATION_CAP_MS, ms * ESCALATION_MULTIPLIER ** (n - 1)))`
37
+ * for `n >= 1`. `n = 1` is flat `ms`; the `max(ms, ...)` floor keeps
38
+ * escalation from ever shortening below the configured flat cooldown when
39
+ * `ms >= ESCALATION_CAP_MS`; `ms = 0` degenerates to today's flat-0
40
+ * semantics.
41
+ */
42
+ export declare function escalatedCooldownMs(ms: number, n: number): number;
43
+ /**
44
+ * Per-route half-open recovery state (plan P2). Pure logic — no host
45
+ * dependencies, no `@deepseek-ai/*` imports.
46
+ */
47
+ export declare class RecoveryStore {
48
+ private entries;
49
+ /**
50
+ * Record a suppression write for `key` (rule 1): increments the
51
+ * consecutive-failure counter and clears any in-progress half-open episode
52
+ * (a failure while half-open re-suppresses escalated — rule 5). Returns the
53
+ * new counter value `n`; the caller computes the escalated duration with
54
+ * {@link escalatedCooldownMs}.
55
+ */
56
+ recordFailure(key: string): number;
57
+ /**
58
+ * Open a half-open episode for `key` (rule 3): the lapsed cooldown leaves
59
+ * the route half-open for one logged probe. Resets the per-episode probe
60
+ * marker; the consecutive-failure counter survives the episode so a probe
61
+ * failure escalates to `n + 1` (rule 5). Creates the entry on first sight
62
+ * (mid-session mode flips can surface a cooldown entry with no recovery
63
+ * entry — rule 8).
64
+ */
65
+ markHalfOpen(key: string, until: number): void;
66
+ /**
67
+ * Mark the episode's one logged admission (rule 4). Returns `true` only for
68
+ * the first admission of a half-open episode; later admissions while the
69
+ * episode is unresolved return `false` and route normally (the marker is
70
+ * not a gate — there is no admission limit).
71
+ */
72
+ tryMarkProbeLogged(key: string): boolean;
73
+ /** True while `key` has an in-progress half-open episode (rule 5b gate). */
74
+ isHalfOpen(key: string): boolean;
75
+ /**
76
+ * Close the circuit for `key` (rule 6): acts only when the entry exists and
77
+ * is half-open — the entry is deleted (counter reset by deletion, preference
78
+ * fully restored). A completion observed while the route is actively
79
+ * suppressed, or with no entry, is a no-op: a stale in-flight success must
80
+ * not cancel a fresher escalated re-suppression.
81
+ */
82
+ close(key: string): void;
83
+ /**
84
+ * The in-progress half-open episodes, in insertion order (display path —
85
+ * plan P4). `untilEpochMs` is the lapsed expiry epoch that drives the
86
+ * `/fallbacks` marker row.
87
+ */
88
+ halfOpenEntries(): Array<{
89
+ key: string;
90
+ untilEpochMs: number;
91
+ }>;
92
+ }
package/dist/state.d.ts CHANGED
@@ -29,6 +29,8 @@
29
29
  */
30
30
  import type { FallbackSwitchReason } from './events.ts';
31
31
  import { CooldownStore, StepFailureSet } from './cooldown.ts';
32
+ import { RecoveryStore } from './recovery.ts';
33
+ import type { RecoveryPolicy } from './config.ts';
32
34
  /** Decision awaiting application at the next `agent/request` boundary (spec §5.1). */
33
35
  export interface PendingSwitch {
34
36
  from: {
@@ -64,6 +66,8 @@ export interface AgentFallbackState {
64
66
  stepFailures: StepFailures;
65
67
  /** `provider/model → expiry epoch ms`; lazily expired on read. */
66
68
  cooldown: CooldownStore;
69
+ /** Half-open recovery bookkeeping (plan fallbacks-half-open-recovery P2). */
70
+ recovery: RecoveryStore;
67
71
  }
68
72
  /**
69
73
  * The `Map<agent.id, AgentFallbackState>` store plus the state-machine
@@ -107,6 +111,27 @@ export declare class FallbackStateStore {
107
111
  clearStepState(state: AgentFallbackState): void;
108
112
  /** Suppress `key` until `untilEpochMs` (`Infinity` for `revertPolicy: 'never'`). */
109
113
  suppress(state: AgentFallbackState, key: string, untilEpochMs: number): void;
110
- /** Lazy cooldown read: expired entries are dropped on read (spec §5.1). */
111
- isSuppressed(state: AgentFallbackState, key: string, now?: number): boolean;
114
+ /**
115
+ * Lazy cooldown read (spec §5.1; plan fallbacks-half-open-recovery P2
116
+ * rule 3). Under `'timer'` (the default) today's body runs verbatim:
117
+ * expired entries are dropped on read. Under `'half-open'` the raw `until`
118
+ * is read via `CooldownStore.peek` (no lazy drop): absent ⇒ false;
119
+ * `until > now` ⇒ true; `until <= now` ⇒ the entry is dropped via the
120
+ * existing `isSuppressed` and the route is marked half-open, returning
121
+ * false. `Infinity` (`revertPolicy: 'never'`) never satisfies `until <=
122
+ * now` ⇒ the never short-circuit is structural on the read side.
123
+ */
124
+ isSuppressed(state: AgentFallbackState, key: string, now?: number, recovery?: RecoveryPolicy): boolean;
125
+ /**
126
+ * Bulk half-open transition for the display path (plan P4): walks the
127
+ * cooldown keys applying rule 3 per key. A pure no-op under `'timer'` —
128
+ * internal state and output stay byte-identical in default mode.
129
+ */
130
+ syncRecovery(state: AgentFallbackState, now: number, mode: RecoveryPolicy): void;
131
+ /**
132
+ * Observed completion ⇒ close the circuit (plan P2 rule 6): acts only when
133
+ * the entry exists and is half-open; otherwise a no-op (a stale in-flight
134
+ * success must not cancel a fresher escalated re-suppression).
135
+ */
136
+ observeSuccess(state: AgentFallbackState, key: string): void;
112
137
  }
@@ -58,9 +58,9 @@ export interface PresetDefinition {
58
58
  label: string;
59
59
  }
60
60
  /**
61
- * Frozen preset windows (UTC+8). `liang-*` presets have NO day mask (they
62
- * apply every day, weekends included); `glm-peak` is Monday–Friday only.
63
- * The two valleys are `complement: true` of their peak.
61
+ * Frozen preset windows (UTC+8). `liang-peak` and `glm-peak` are
62
+ * Monday–Friday only (day mask `[1, 2, 3, 4, 5]`); their valleys are the
63
+ * `complement: true` of the corresponding peak (weekends included).
64
64
  */
65
65
  export declare const PRESETS: Record<PresetId, PresetDefinition>;
66
66
  /**
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "dsh",
6
6
  "dsh-plugin"
7
7
  ],
8
- "version": "0.3.3",
8
+ "version": "0.3.5",
9
9
  "type": "module",
10
10
  "main": "./dist/index.js",
11
11
  "types": "./dist/index.d.ts",
@@ -61,23 +61,23 @@
61
61
  },
62
62
  "peerDependencies": {
63
63
  "@deepseek-ai/cordis": "^4.0.1",
64
- "@deepseek-ai/dsh-agent": "^0.1.1-rc.1",
65
- "@deepseek-ai/dsh-api-gateway": "^0.1.1-rc.1",
66
- "@deepseek-ai/dsh-api-remotes": "^0.1.1-rc.1",
67
- "@deepseek-ai/dsh-client-connection": "^0.1.1-rc.1",
68
- "@deepseek-ai/dsh-client-locale": "^0.1.1-rc.1",
69
- "@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.1",
70
- "@deepseek-ai/dsh-client-ui-conversation": "^0.1.1-rc.1",
71
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.1-rc.1",
72
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.1-rc.1",
73
- "@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.1-rc.1",
74
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.1",
75
- "@deepseek-ai/dsh-commands": "^0.1.1-rc.1",
76
- "@deepseek-ai/dsh-llm": "^0.1.1-rc.1",
77
- "@deepseek-ai/dsh-session": "^0.1.1-rc.1",
78
- "@deepseek-ai/dsh-settings": "^0.1.1-rc.1",
79
- "@deepseek-ai/dsh-typert-protocol": "^0.1.1-rc.1",
80
- "@deepseek-ai/dsh-typert-registry": "^0.1.1-rc.1",
64
+ "@deepseek-ai/dsh-agent": "^0.1.1-rc.2",
65
+ "@deepseek-ai/dsh-api-gateway": "^0.1.1-rc.2",
66
+ "@deepseek-ai/dsh-api-remotes": "^0.1.1-rc.2",
67
+ "@deepseek-ai/dsh-client-connection": "^0.1.1-rc.2",
68
+ "@deepseek-ai/dsh-client-locale": "^0.1.1-rc.2",
69
+ "@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",
70
+ "@deepseek-ai/dsh-client-ui-conversation": "^0.1.1-rc.2",
71
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.1-rc.2",
72
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.1-rc.2",
73
+ "@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.1-rc.2",
74
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.2",
75
+ "@deepseek-ai/dsh-commands": "^0.1.1-rc.2",
76
+ "@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
77
+ "@deepseek-ai/dsh-session": "^0.1.1-rc.2",
78
+ "@deepseek-ai/dsh-settings": "^0.1.1-rc.2",
79
+ "@deepseek-ai/dsh-typert-protocol": "^0.1.1-rc.2",
80
+ "@deepseek-ai/dsh-typert-registry": "^0.1.1-rc.2",
81
81
  "@deepseek-ai/schemastery": "^3.18.1",
82
82
  "react": "^18.2.0"
83
83
  },