dsh-llm-fallbacks 0.4.3 → 0.4.4
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 +14 -15
- package/README.zh-CN.md +14 -15
- package/dist/client/index.js +57 -54
- package/dist/client/locales.d.ts +2 -0
- package/dist/events.d.ts +5 -3
- package/dist/index.js +10 -7
- package/dist/time-slots.d.ts +19 -10
- package/package.json +44 -24
- package/scripts/build-client.ts +9 -5
- package/scripts/repair-fallbacks-switch-logs.ts +97 -129
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|

|
|
8
8
|

|
|
9
9
|

|
|
10
|
-

|
|
11
11
|
[](https://dshfind.com/zh/plugins/omdsh-dev/dsh-llm-fallbacks?ref=badge)
|
|
12
12
|
|
|
13
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.
|
|
@@ -31,7 +31,7 @@ Four frozen UTC+8 presets (windows are code constants; preset rows lock `tz` to
|
|
|
31
31
|
|
|
32
32
|
GLM Peak and GLM Valley are offered in the card picker only when `zai-coding-cn` is configured.
|
|
33
33
|
|
|
34
|
-
The first extra row whose window contains the current moment (in `fallbacks.tz`, default Asia/Shanghai) wins; no match → the all-day `rootChain`, whose tail (Default model) must be exactly one official
|
|
34
|
+
The first extra row whose window contains the current moment (in `fallbacks.tz`, default Asia/Shanghai) wins; no match → the all-day `rootChain`, whose tail (Default model) must be exactly one official model — `deepseek-official/deepseek-flash` or `deepseek-official/deepseek-pro` (XOR). Slot rotation is a routing seed, not a failure decision: it applies on the next root request, consumes no cooldown, and is logged as a time-slot switch — failure walks keep fallback switch. Full semantics → [Time-slot presets](#time-slot-presets) and [docs/configuration.md](docs/configuration.md).
|
|
35
35
|
|
|
36
36
|
## Quick start
|
|
37
37
|
|
|
@@ -46,17 +46,16 @@ Same plugin, either front end — the only difference is the `--profile` flag. P
|
|
|
46
46
|
|
|
47
47
|
### Repair existing sessions (versions before 0.2.2)
|
|
48
48
|
|
|
49
|
-
Versions before 0.2.2 wrote durable `fallbacks/switch` session events that newer dsh releases refuse to load (issue #52 — the apply()-time event-type registration is ineffective because plugin and host resolve different module instances). If existing sessions fail to open after an upgrade, clone this repository and
|
|
49
|
+
Versions before 0.2.2 wrote durable `fallbacks/switch` session events that newer dsh releases refuse to load (issue #52 — the apply()-time event-type registration is ineffective because plugin and host resolve different module instances). If existing sessions fail to open after an upgrade, clone this repository and run the detector:
|
|
50
50
|
|
|
51
51
|
```sh
|
|
52
52
|
git clone https://github.com/omdsh-dev/dsh-llm-fallbacks.git
|
|
53
53
|
cd dsh-llm-fallbacks
|
|
54
54
|
pnpm install
|
|
55
|
-
pnpm repair:fallbacks-switch-logs -- --dry-run
|
|
56
|
-
pnpm repair:fallbacks-switch-logs -- --apply --backup # mark legacy events ignorable
|
|
55
|
+
pnpm repair:fallbacks-switch-logs -- --dry-run
|
|
57
56
|
```
|
|
58
57
|
|
|
59
|
-
The script scans `~/.dsh/sessions` by default (override with `--root <dir>`)
|
|
58
|
+
The script scans `~/.dsh/sessions` by default (override with `--root <dir>`). **It cannot repair these logs**: the released session-format migration chain (v0→v1) refuses unknown event types even when marked `ignorable: true`, so a "repaired" log would still be rejected on load. The script therefore fails closed — it only reports which sessions contain `fallbacks/switch` events, never writes or backs up any file, and exits non-zero when it finds any (the legacy `--apply` / `--backup` flags are accepted as no-ops). The durable fix belongs upstream at the migration edges. From 0.2.2 on, the plugin stops writing durable switch events, so no new sessions need attention.
|
|
60
59
|
|
|
61
60
|
### Configuration surfaces
|
|
62
61
|
|
|
@@ -77,9 +76,9 @@ Add a `fallbacks:` section to the shared settings document (`$DSH_HOME/settings.
|
|
|
77
76
|
```yaml
|
|
78
77
|
fallbacks:
|
|
79
78
|
enabled: true # feature switch — defaults to off (plugin is a no-op otherwise)
|
|
80
|
-
rootChain: # all-day chain: leading entries = fallback walk, last = Default model (official
|
|
79
|
+
rootChain: # all-day chain: leading entries = fallback walk, last = Default model (official)
|
|
81
80
|
- anthropic/claude-3-5-sonnet # walked first
|
|
82
|
-
- deepseek-official/deepseek-
|
|
81
|
+
- deepseek-official/deepseek-flash # last resort (Flash or Pro)
|
|
83
82
|
timeSlots: # optional: rotate the effective root chain by wall-clock windows
|
|
84
83
|
- kind: preset # frozen UTC+8 window; only the chain is editable
|
|
85
84
|
preset: liang-peak # Monday–Friday 09:00–12:00 and 14:00–18:00
|
|
@@ -109,7 +108,7 @@ Build the section up in four steps:
|
|
|
109
108
|
|
|
110
109
|
**2. Set the all-day `rootChain`.** Leading entries are the fallback chain, walked first when a request fails; the **last** entry is the Default model.
|
|
111
110
|
|
|
112
|
-
> **Conformance**: the last entry must be exactly one official
|
|
111
|
+
> **Conformance**: the last entry must be exactly one official model — `deepseek-official/deepseek-flash` or `deepseek-official/deepseek-pro` (XOR). The settings card and gateway reject any other tail on save; a legacy non-official tail warns at startup and keeps working as a fallback-only walk, but cannot be saved as-is. The retired `deepseek-v4-flash` / `deepseek-v4-pro` ids are no longer legal tails — a saved V4 tail now warns, goes inert (slot rows + virtual picker), and blocks save until a legal tail is picked. `deepseek-pro` is a legal selector whose model is not yet served by the catalog: the card shows it disabled ("not yet available"), and requests to it fail at the provider until the gateway enables the id. The plugin does not probe catalog availability — a chain containing `deepseek-pro` dispatches to it like any other exact entry, and the override resolves the first exact head of the effective chain, so a chain with a working entry before Pro still routes to that earlier entry.
|
|
113
112
|
|
|
114
113
|
**3. Add `timeSlots` (optional).** Rows rotate the effective root chain by wall-clock windows. Preset rows use frozen UTC+8 windows (only their chain is editable; while a preset row exists, `tz` locks to `Asia/Shanghai`); custom rows take `start`/`end` (may wrap midnight) and an optional `days` list. The first row whose window contains the current moment wins; no match → the all-day `rootChain`. Rotation is a routing seed — it applies on the next root request and consumes no cooldown (see [Time slots](#time-slots)).
|
|
115
114
|
|
|
@@ -135,7 +134,7 @@ Save the config and restart the session, then type `/fallbacks` — the read-onl
|
|
|
135
134
|
- **Cooldown and revert**: failed / switched-away models are not re-selected during cooldown; `revertPolicy: cooldown-expiry` returns to the primary model automatically.
|
|
136
135
|
- **Host subagent model policy (dsh 0.1.2)**: when the host `subagent-model-selection` policy is enabled, its allowlist is a hard constraint on every plugin-originated subagent route — an explicit authorized spawn route stays the chain head (role-inject skipped), inheritance inject heads and failure-switch targets are intersected with the effective allowlist, and an empty intersection skips the inject/switch (warn log + read-only card warning; no out-of-allowlist request is ever sent). A present-but-unreadable policy fails closed. Policy off/absent → inject and failure-switch selection exactly as 0.3.5. Override `reasoningEffort` follows the upstream routeChanged rule on every path (same route → keep; route change → drop unless explicit). See [Host subagent model policy](#host-subagent-model-policy-dsh-012).
|
|
137
136
|
- **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)).
|
|
138
|
-
- **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
|
|
137
|
+
- **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 **cannot** be repaired by an `ignorable` flag — the released session-format migration chain (v0→v1) refuses unknown event types even when marked ignorable — so `scripts/repair-fallbacks-switch-logs.ts` now fails closed and only reports such logs (the durable fix belongs upstream at the migration edges).
|
|
139
138
|
- **Safety valves**: `maxSwitchesPerStep` caps switches per step and `alwaysModeRetryCap` caps always-mode retries — chain loops cannot amplify latency.
|
|
140
139
|
- **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)).
|
|
141
140
|
|
|
@@ -161,9 +160,9 @@ There is **no `rootMode` switch** — no config key, YAML field, settings toggle
|
|
|
161
160
|
|
|
162
161
|
Notes:
|
|
163
162
|
|
|
164
|
-
- **Picker label**: the row's catalog `name` (what the composer trigger shows) is live — `Auto: DeepSeek
|
|
163
|
+
- **Picker label**: the row's catalog `name` (what the composer trigger shows) is live — `Auto: DeepSeek Flash[Liang Peak]` / `Auto: DeepSeek Flash[all-day]` (catalog display name, not the model id); the id stays `Auto`. Bare `Auto` if the all-day tail is not conforming. Refresh by reopening the picker.
|
|
165
164
|
- **Root only**: the row is about the root agent. Subagent role resolution and injection are unchanged; a subagent session that inherits the selection still routes through the chain head — the virtual row is a thin delegate, never a second routing engine.
|
|
166
|
-
- **Conformance gate on the tail**: a successful override/delegate requires the all-day chain to be **tail-conforming** — its last entry must be exactly one official
|
|
165
|
+
- **Conformance gate on the tail**: a successful override/delegate requires the all-day chain to be **tail-conforming** — its last entry must be exactly one official model (`deepseek-official/deepseek-flash` or `deepseek-official/deepseek-pro`, the card's Default model panel); leading entries (Default fallback chain) are walked first. Disabling the plugin hides the row again (slot-row/chain edits never churn registration).
|
|
167
166
|
- **Stale selection**: if the row disappears (plugin disabled) while `FallbacksChain / Auto` is selected, the session keeps showing it as the current model with `routable: false` — pick a real model from the catalog to continue (host-native catalog semantics).
|
|
168
167
|
- **Capabilities follow the head**: the row's model metadata (context window, modalities, reasoning) mirrors the current effective head; retry attribution follows the permissive default — retries/failures are accounted to the real head pair, not to the `FallbacksChain` provider. Full semantics → [docs/configuration.md](docs/configuration.md).
|
|
169
168
|
|
|
@@ -171,11 +170,11 @@ Notes:
|
|
|
171
170
|
|
|
172
171
|
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.
|
|
173
172
|
|
|
174
|
-
- **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
|
|
173
|
+
- **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 model (Flash / Pro XOR; leading Default fallback chain entries are walked first).
|
|
175
174
|
- **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.
|
|
176
175
|
- **Custom rows**: `start` / `end` (`HH:mm`, may wrap midnight) + optional `days` (0=Sunday…6=Saturday; omitted/empty = every day) + models.
|
|
177
176
|
- **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.
|
|
178
|
-
- **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
|
|
177
|
+
- **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 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.
|
|
179
178
|
|
|
180
179
|
## Preset roles
|
|
181
180
|
|
|
@@ -202,7 +201,7 @@ fallbacks:
|
|
|
202
201
|
- CONTEXT_WINDOW_EXCEEDED # fail over when the request does not fit
|
|
203
202
|
rootChain:
|
|
204
203
|
- anthropic/claude-3-5-sonnet # walked first
|
|
205
|
-
- deepseek-official/deepseek-
|
|
204
|
+
- deepseek-official/deepseek-flash # last resort (Flash or Pro)
|
|
206
205
|
```
|
|
207
206
|
|
|
208
207
|
**Ordering — the fallback runs before compaction.** With `CONTEXT_WINDOW_EXCEEDED` in `triggerCodes`, this plugin's `agent/request-error` listener handles the rejection **before** the harness's compaction plugin gets to compact: the conversation moves to the fallback model on the first overflow, and the context is never compacted. Leave the code out of `triggerCodes` if you would rather have compaction tried first.
|
package/README.zh-CN.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|

|
|
8
8
|

|
|
9
9
|

|
|
10
|
-

|
|
11
11
|
[](https://dshfind.com/zh/plugins/omdsh-dev/dsh-llm-fallbacks?ref=badge)
|
|
12
12
|
|
|
13
13
|
dsh(DeepSeek Harness)的自动模型降级插件:当 root agent 或 subagent 的模型请求持续失败(重试耗尽、权限、配额超限、限流 429)时,按角色/模型 fallback 链自动切换 provider/model,当前 step/turn 在目标模型上继续完成——任务不因模型问题中断。
|
|
@@ -31,7 +31,7 @@ dsh(DeepSeek Harness)的自动模型降级插件:当 root agent 或 subage
|
|
|
31
31
|
|
|
32
32
|
GLM 峰与 GLM 谷仅在已配置 `zai-coding-cn` 时出现在设置卡选择器中。
|
|
33
33
|
|
|
34
|
-
每个 root 请求时刻,第一条窗口包含当前时刻(按 `fallbacks.tz`,默认 Asia/Shanghai)的额外行生效;无行命中 → 全时段 `rootChain
|
|
34
|
+
每个 root 请求时刻,第一条窗口包含当前时刻(按 `fallbacks.tz`,默认 Asia/Shanghai)的额外行生效;无行命中 → 全时段 `rootChain`——其链尾(默认模型)必须是恰好一个官方模型:`deepseek-official/deepseek-flash` 或 `deepseek-official/deepseek-pro`(二选一)。分时切换是路由种子而非失败决策:在下一个 root 请求生效、不消耗冷却、不计入 `maxSwitchesPerStep`,日志记为**分时切换**;失败降级保持**降级切换**。完整语义 → [分时槽预设(分时切换)](#分时槽预设分时切换) 与 [docs/configuration.md](docs/configuration.md)。
|
|
35
35
|
|
|
36
36
|
## 快速开始
|
|
37
37
|
|
|
@@ -46,17 +46,16 @@ dsh plugin --profile dsh-tui add dsh-llm-fallbacks # dsh-tui 终端 profile
|
|
|
46
46
|
|
|
47
47
|
### 修复旧会话(0.2.2 之前的版本)
|
|
48
48
|
|
|
49
|
-
0.2.2 之前的版本会把 `fallbacks/switch` 事件写入会话持久化日志,而新版 dsh 拒绝加载这类会话(issue #52——apply() 时的注册因插件与宿主解析到不同模块实例而无效)。如果升级后已有会话打不开,clone
|
|
49
|
+
0.2.2 之前的版本会把 `fallbacks/switch` 事件写入会话持久化日志,而新版 dsh 拒绝加载这类会话(issue #52——apply() 时的注册因插件与宿主解析到不同模块实例而无效)。如果升级后已有会话打不开,clone 本仓库并运行检测器:
|
|
50
50
|
|
|
51
51
|
```sh
|
|
52
52
|
git clone https://github.com/omdsh-dev/dsh-llm-fallbacks.git
|
|
53
53
|
cd dsh-llm-fallbacks
|
|
54
54
|
pnpm install
|
|
55
|
-
pnpm repair:fallbacks-switch-logs -- --dry-run
|
|
56
|
-
pnpm repair:fallbacks-switch-logs -- --apply --backup # 给旧事件打 ignorable 标记
|
|
55
|
+
pnpm repair:fallbacks-switch-logs -- --dry-run
|
|
57
56
|
```
|
|
58
57
|
|
|
59
|
-
脚本默认扫描 `~/.dsh/sessions`(可用 `--root <dir>`
|
|
58
|
+
脚本默认扫描 `~/.dsh/sessions`(可用 `--root <dir>` 覆盖)。**它无法修复这些日志**:已发布的 session-format 迁移链(v0→v1)即使事件带 `ignorable: true` 也拒绝未知事件类型,因此「修复后」的日志仍会被拒绝加载。脚本因此 fail-closed——只报告哪些会话含 `fallbacks/switch` 事件,绝不写入或备份任何文件,发现即非零退出(遗留的 `--apply` / `--backup` 参数按 no-op 接受)。持久修复应在上游迁移边界完成。从 0.2.2 起插件不再写 durable 切换事件,新会话无需处理。
|
|
60
59
|
|
|
61
60
|
### 配置界面
|
|
62
61
|
|
|
@@ -77,9 +76,9 @@ pnpm repair:fallbacks-switch-logs -- --apply --backup # 给旧事件打 igno
|
|
|
77
76
|
```yaml
|
|
78
77
|
fallbacks:
|
|
79
78
|
enabled: true # 功能开关——默认关闭(否则插件完全 no-op)
|
|
80
|
-
rootChain: # 全时段链:前面的条目 = 降级路径,最后一项 =
|
|
79
|
+
rootChain: # 全时段链:前面的条目 = 降级路径,最后一项 = 默认模型(官方模型)
|
|
81
80
|
- anthropic/claude-3-5-sonnet # 先走
|
|
82
|
-
- deepseek-official/deepseek-
|
|
81
|
+
- deepseek-official/deepseek-flash # 最后一档(Flash 或 Pro)
|
|
83
82
|
timeSlots: # 可选:按墙钟窗口轮换生效 root 链
|
|
84
83
|
- kind: preset # 冻结的 UTC+8 窗口;仅链可编辑
|
|
85
84
|
preset: liang-peak # 周一至周五 09:00–12:00 与 14:00–18:00
|
|
@@ -109,7 +108,7 @@ fallbacks:
|
|
|
109
108
|
|
|
110
109
|
**2. 配置全时段 `rootChain`。** 前面的条目是降级链,请求失败时先走;**最后**一项是默认模型。
|
|
111
110
|
|
|
112
|
-
>
|
|
111
|
+
> **链尾合规**:最后一项必须是恰好一个官方模型——`deepseek-official/deepseek-flash` 或 `deepseek-official/deepseek-pro`(二选一)。设置卡与 gateway 在保存时拒绝其它尾巴;遗留的非合规尾巴启动时告警并继续按 fallback-only 走原链,但无法原样保存。已退役的 `deepseek-v4-flash` / `deepseek-v4-pro` 不再是合法链尾——已保存的 V4 尾巴现在会告警、进入惰性(分时行 + 虚拟选择器)、并在选到合法链尾前阻止保存。`deepseek-pro` 是合法选择器但其模型尚未进入目录:设置卡中显示为禁用(「暂不可用」),请求在 gateway 启用该 id 前会在 provider 处失败。插件不探测目录可用性——含 `deepseek-pro` 的链会像其它精确条目一样派发到它;覆盖解析生效链的第一个精确链头,因此 Pro 之前有可用条目的链仍路由到更早的条目。
|
|
113
112
|
|
|
114
113
|
**3. 添加 `timeSlots`(可选)。** 各行按墙钟窗口轮换生效 root 链。预设行使用冻结的 UTC+8 窗口(仅链可编辑;存在预设行时 `tz` 锁定 `Asia/Shanghai`);自定义行使用 `start`/`end`(可跨午夜)与可选的 `days` 列表。第一个窗口包含当前时刻的行生效;无行命中 → 全时段 `rootChain`。分时切换是路由种子——在下一个 root 请求生效、不消耗冷却(见 [峰谷无忧](#峰谷无忧))。
|
|
115
114
|
|
|
@@ -135,7 +134,7 @@ fallbacks:
|
|
|
135
134
|
- **冷却与回主**:被切离/失败的模型在冷却期内不再入选;`revertPolicy: cooldown-expiry` 冷却到期后自动回主模型。
|
|
136
135
|
- **宿主子代理模型策略(dsh 0.1.2)**:当宿主 `subagent-model-selection` 策略启用时,其允许列表对每个插件发起的 subagent 路由都是硬约束——显式授权的派发路由保持为链头(跳过角色注入),继承注入的链头与失败切换目标都与生效允许列表求交集,交集为空则跳过注入/切换(warn 日志 + 只读卡片警告;绝不发送允许列表之外的请求)。策略存在但不可读时 fail-closed。策略关闭/缺省时,注入与失败切换的选择与 0.3.5 完全一致。覆盖路径上的 `reasoningEffort` 遵循上游 routeChanged 规则(同路由 → 保留;跨路由 → 除非显式指定否则丢弃)。见 [宿主子代理模型策略](#宿主子代理模型策略dsh-012)。
|
|
137
136
|
- **半开恢复(可选)**:`recovery: half-open` 让恢复以证据驱动——冷却到期后路由进入 **half-open**,以一次记录探针(logged probe)放行,而不是直接恢复首选;连续失败使抑制时长按 **×2** 逐次升级、**1 小时**封顶;观察到完成即闭合回路、完全恢复首选。`revertPolicy: 'never'` 使该机制完全失效;状态为会话级内存态(重启即重置)。仅 YAML 配置——默认 `timer` 保持所有既有行为逐字节一致(见 [docs/configuration.md](docs/configuration.md#recovery-mode-recovery-key))。
|
|
138
|
-
- **行为可见**:每次切换以 info 级日志行(from/to/role/reason)记录——无静默换模型。插件**刻意不写** durable `fallbacks/switch` 会话事件(issue #52——apply() 时的事件类型注册被证伪无效,含该事件的会话在 dsh
|
|
137
|
+
- **行为可见**:每次切换以 info 级日志行(from/to/role/reason)记录——无静默换模型。插件**刻意不写** durable `fallbacks/switch` 会话事件(issue #52——apply() 时的事件类型注册被证伪无效,含该事件的会话在 dsh 重启后拒绝加载)。由旧版插件写入、含此类事件的会话**无法**通过 `ignorable` 标记修复——已发布的 session-format 迁移链(v0→v1)即使事件带 `ignorable` 也拒绝未知事件类型——因此 `scripts/repair-fallbacks-switch-logs.ts` 现在 fail-closed,只报告此类日志(持久修复应在上游迁移边界完成)。
|
|
139
138
|
- **安全阀**:`maxSwitchesPerStep` 限制每 step 切换次数、`alwaysModeRetryCap` 限制 always 模式重试——链循环不会放大延迟。
|
|
140
139
|
- **无配置回归(no-op)**:未配置任何链时行为与未安装插件完全一致——`enabled` 默认关闭(见 [最小配置](#最小配置))。
|
|
141
140
|
|
|
@@ -161,9 +160,9 @@ fallbacks:
|
|
|
161
160
|
|
|
162
161
|
注意:
|
|
163
162
|
|
|
164
|
-
- **选择器文案**:目录行的 `name`(composer 触发器显示)是动态的——`Auto: DeepSeek
|
|
163
|
+
- **选择器文案**:目录行的 `name`(composer 触发器显示)是动态的——`Auto: DeepSeek Flash[Liang Peak]` / `Auto: DeepSeek Flash[all-day]`(用 catalog 显示名,不是 model id);id 仍是 `Auto`。all-day 尾巴不合规则只显示 `Auto`。重新打开选择器即可刷新。
|
|
165
164
|
- **仅 root**:这一行只关乎 root 代理。subagent 的角色解析与注入不变;继承了该选择的 subagent 会话仍经链头路由——虚拟行只是薄委托,绝不是第二个路由引擎。
|
|
166
|
-
- **链尾合规门槛**:覆盖/委托成功要求 all-day
|
|
165
|
+
- **链尾合规门槛**:覆盖/委托成功要求 all-day 链**尾巴合规**——最后一项必须是恰好一个官方模型(`deepseek-official/deepseek-flash` 或 `deepseek-official/deepseek-pro`,即设置卡的「默认模型」面板);前面的默认降级链先走。禁用插件后该行隐藏(slot/链编辑不会触发注册抖动)。
|
|
167
166
|
- **过期选择**:行消失(插件禁用)而会话仍选中 `FallbacksChain / Auto` 时,会话继续把它显示为当前模型,但 `routable: false`——从目录选一个真实模型即可继续(宿主原生目录语义)。
|
|
168
167
|
- **能力跟随链头**:该行的模型元数据(上下文窗口、模态、推理)镜像当前生效链头;重试归属保持宽松默认——重试/失败记到被委托的真实链头,而非 `FallbacksChain` provider。完整语义 → [docs/configuration.md](docs/configuration.md)。
|
|
169
168
|
|
|
@@ -171,11 +170,11 @@ fallbacks:
|
|
|
171
170
|
|
|
172
171
|
峰谷无忧在[首页专题](#峰谷无忧)中介绍,本节是完整参考。分时槽行按墙钟窗口轮换**生效 root 链**——适合按峰谷切换模型,且不会把墙钟轮换误认为故障降级。文案严格区分:时段轮换的日志与 UI 用**分时切换**;失败降级保持**降级切换**;会话内「模型已降级」提示只出现在失败路径。
|
|
173
172
|
|
|
174
|
-
- **匹配顺序**:每个 root 请求时刻,第一条窗口包含当前时刻(按 `fallbacks.tz`,默认 `Asia/Shanghai` / UTC+8)的额外行生效——该行的模型链**取代**全时段链;无行命中则用全时段 `rootChain
|
|
173
|
+
- **匹配顺序**:每个 root 请求时刻,第一条窗口包含当前时刻(按 `fallbacks.tz`,默认 `Asia/Shanghai` / UTC+8)的额外行生效——该行的模型链**取代**全时段链;无行命中则用全时段 `rootChain`。全时段行固定最后且**必选**:最后一项必须是恰好一个官方模型(Flash 或 Pro;前面的降级条目先走)。
|
|
175
174
|
- **预设**(冻结,不可编辑窗口):`liang-peak` = 周一至周五 09:00–12:00 **与** 14:00–18:00;`liang-valley` = 其它所有 UTC+8 时间;`glm-peak` = 周一至周五 14:00–18:00;`glm-valley` = 其余时间。一个预设 id 对应一行;设置卡的选择器不会重复提供已添加的预设。
|
|
176
175
|
- **自定义行**:`start` / `end`(`HH:mm`,可跨午夜)+ 可选 `days`(0=周日…6=周六;缺省/空 = 每天)+ 模型。
|
|
177
176
|
- **下一请求生效**:时段边界跨越绝不打断进行中的 step——新行在下一个 root 请求生效。轮换仅挂载生效:info 日志 + 设置卡/`/fallbacks` 状态行,无 durable 切换事件。
|
|
178
|
-
- **设置卡**:主代理区块下分三块——**分时槽设置**(额外行:添加预设 / 添加自定义 / 删除 / 按钮或**拖拽**排序;预设行只读展示窗口摘要、仅可编辑模型链;自定义行带可编辑名称;**时区选择器**在此区块内,只要存在预设行就**锁定 Asia/Shanghai**——预设窗口是冻结的 UTC+8 常量)、**默认降级链**(all-day 链,可配置的 provider/model 选择器列表)与**默认模型**(官方
|
|
177
|
+
- **设置卡**:主代理区块下分三块——**分时槽设置**(额外行:添加预设 / 添加自定义 / 删除 / 按钮或**拖拽**排序;预设行只读展示窗口摘要、仅可编辑模型链;自定义行带可编辑名称;**时区选择器**在此区块内,只要存在预设行就**锁定 Asia/Shanghai**——预设窗口是冻结的 UTC+8 常量)、**默认降级链**(all-day 链,可配置的 provider/model 选择器列表)与**默认模型**(官方 Flash | Pro 二选一链头)。行可折叠为「名称 + 首个模型」。没有 `timeSlots.enabled` 总开关(添加行即开启),也没有 `rootMode` 控件。
|
|
179
178
|
|
|
180
179
|
## 预设角色(Preset roles)
|
|
181
180
|
|
|
@@ -202,7 +201,7 @@ fallbacks:
|
|
|
202
201
|
- CONTEXT_WINDOW_EXCEEDED # 请求装不下时降级
|
|
203
202
|
rootChain:
|
|
204
203
|
- anthropic/claude-3-5-sonnet # 先走
|
|
205
|
-
- deepseek-official/deepseek-
|
|
204
|
+
- deepseek-official/deepseek-flash # 兜底(Flash 或 Pro)
|
|
206
205
|
```
|
|
207
206
|
|
|
208
207
|
**顺序——降级先于压缩。** 只要 `triggerCodes` 含 `CONTEXT_WINDOW_EXCEEDED`,本插件的 `agent/request-error` 监听就会**先于**宿主压缩插件处理该拒绝:首次超限即把会话切到 fallback 模型,上下文完全不会被压缩。若更希望先尝试压缩,就不要把该码列入 `triggerCodes`。
|
package/dist/client/index.js
CHANGED
|
@@ -198,6 +198,12 @@ window.__ModuleLoader__.load({
|
|
|
198
198
|
raw: trimmed
|
|
199
199
|
};
|
|
200
200
|
}
|
|
201
|
+
/** The two official all-day tail ids (XOR — exactly one legal tail). */
|
|
202
|
+
const OFFICIAL_ALL_DAY_IDS = ["deepseek-official/deepseek-flash", "deepseek-official/deepseek-pro"];
|
|
203
|
+
/** Whether `id` is one of the two official all-day tail ids. */
|
|
204
|
+
function isOfficialAllDayId(id) {
|
|
205
|
+
return id === "deepseek-official/deepseek-flash" || id === "deepseek-official/deepseek-pro";
|
|
206
|
+
}
|
|
201
207
|
/**
|
|
202
208
|
* Frozen preset windows (UTC+8). `liang-peak` and `glm-peak` are
|
|
203
209
|
* Monday–Friday only (day mask `[1, 2, 3, 4, 5]`); their valleys are the
|
|
@@ -402,17 +408,18 @@ window.__ModuleLoader__.load({
|
|
|
402
408
|
}
|
|
403
409
|
/**
|
|
404
410
|
* All-day conformance (P6): the all-day chain is conforming when its LAST
|
|
405
|
-
* entry (the tail — the card's 默认模型 panel) is exactly one official
|
|
406
|
-
* model — Flash
|
|
407
|
-
* the ordered walk before that last-resort fallback. An empty chain or
|
|
408
|
-
* chain whose tail is not an official
|
|
409
|
-
*
|
|
410
|
-
* the raw chain stays
|
|
411
|
+
* entry (the tail — the card's 默认模型 panel) is exactly one official
|
|
412
|
+
* model — Flash or Pro (XOR). Leading entries (the card's 默认降级链 block)
|
|
413
|
+
* are the ordered walk before that last-resort fallback. An empty chain or
|
|
414
|
+
* a chain whose tail is not an official model (including the retired V4
|
|
415
|
+
* ids) keeps slot rows inert and refuses the virtual-row
|
|
416
|
+
* override/delegate; the v0.2.2 failure walk over the raw chain stays
|
|
417
|
+
* verbatim.
|
|
411
418
|
*/
|
|
412
419
|
function isAllDayConforming(chain) {
|
|
413
420
|
if (chain.length < 1) return false;
|
|
414
421
|
const tail = chain[chain.length - 1];
|
|
415
|
-
return tail
|
|
422
|
+
return isOfficialAllDayId(tail);
|
|
416
423
|
}
|
|
417
424
|
/**
|
|
418
425
|
* Slot winner + display label (P5): drives 分时切换 detection (per-root-agent
|
|
@@ -1468,10 +1475,11 @@ window.__ModuleLoader__.load({
|
|
|
1468
1475
|
"timeSlots.day.fri": "五",
|
|
1469
1476
|
"timeSlots.day.sat": "六",
|
|
1470
1477
|
"timeSlots.selector.add": "添加选择器",
|
|
1471
|
-
"allDay.hint": "全天链的最后一档兜底:官方
|
|
1472
|
-
"allDay.flash": "官方
|
|
1473
|
-
"allDay.pro": "官方
|
|
1474
|
-
"allDay.
|
|
1478
|
+
"allDay.hint": "全天链的最后一档兜底:官方 Flash 或 Pro 二选一",
|
|
1479
|
+
"allDay.flash": "官方 Flash(deepseek-official/deepseek-flash)",
|
|
1480
|
+
"allDay.pro": "官方 Pro(deepseek-official/deepseek-pro)",
|
|
1481
|
+
"allDay.proCaveat": "(暂不可用)",
|
|
1482
|
+
"allDay.nonconforming": "当前默认模型不合法:请选择官方 Flash 或 Pro 后保存",
|
|
1475
1483
|
"roles.list.label": "角色实体",
|
|
1476
1484
|
"roles.list.hint": "先声明角色,规则才能引用",
|
|
1477
1485
|
"roles.list.tooltip": "角色 id 须匹配 /^[a-z0-9-]{1,32}$/ 且唯一;\"inherit\" 为保留字,不能用作角色 id。",
|
|
@@ -1514,7 +1522,7 @@ window.__ModuleLoader__.load({
|
|
|
1514
1522
|
"validation.ruleRoleUndeclared": "规则引用了未声明的角色 \"{role}\"",
|
|
1515
1523
|
"validation.ruleRoleRequired": "规则未选择角色:请选择目标角色,或删除该行",
|
|
1516
1524
|
"validation.roleChainRequired": "角色 \"{id}\" 未配置模型:请至少添加一条链选择器(模型配置)",
|
|
1517
|
-
"validation.allDayRequired": "默认模型必须二选一:官方
|
|
1525
|
+
"validation.allDayRequired": "默认模型必须二选一:官方 Flash 或 Pro",
|
|
1518
1526
|
"validation.slotChainRequired": "分时槽未配置模型:请至少添加一条链选择器",
|
|
1519
1527
|
"validation.slotWindow": "分时槽开始/结束时间须为 HH:mm 格式",
|
|
1520
1528
|
"validation.slotDays": "星期取值须为 0–6 的整数",
|
|
@@ -1663,10 +1671,11 @@ window.__ModuleLoader__.load({
|
|
|
1663
1671
|
"timeSlots.day.fri": "Fri",
|
|
1664
1672
|
"timeSlots.day.sat": "Sat",
|
|
1665
1673
|
"timeSlots.selector.add": "Add selector",
|
|
1666
|
-
"allDay.hint": "Last-resort fallback of the all-day chain: official
|
|
1667
|
-
"allDay.flash": "Official
|
|
1668
|
-
"allDay.pro": "Official
|
|
1669
|
-
"allDay.
|
|
1674
|
+
"allDay.hint": "Last-resort fallback of the all-day chain: official Flash or Pro (pick exactly one)",
|
|
1675
|
+
"allDay.flash": "Official Flash (deepseek-official/deepseek-flash)",
|
|
1676
|
+
"allDay.pro": "Official Pro (deepseek-official/deepseek-pro)",
|
|
1677
|
+
"allDay.proCaveat": " (not yet available)",
|
|
1678
|
+
"allDay.nonconforming": "The current default model is not valid: pick official Flash or Pro before saving",
|
|
1670
1679
|
"roles.list.label": "Declared roles",
|
|
1671
1680
|
"roles.list.hint": "Declare roles before rules can reference them",
|
|
1672
1681
|
"roles.list.tooltip": "Role ids must match /^[a-z0-9-]{1,32}$/ and be unique; \"inherit\" is reserved and cannot be used as a role id.",
|
|
@@ -1709,7 +1718,7 @@ window.__ModuleLoader__.load({
|
|
|
1709
1718
|
"validation.ruleRoleUndeclared": "Rule references undeclared role \"{role}\"",
|
|
1710
1719
|
"validation.ruleRoleRequired": "Rule has no role selected: pick a target role, or remove the row",
|
|
1711
1720
|
"validation.roleChainRequired": "Role \"{id}\" has no model config: add at least one chain entry",
|
|
1712
|
-
"validation.allDayRequired": "The default model must be exactly one official
|
|
1721
|
+
"validation.allDayRequired": "The default model must be exactly one official model (Flash or Pro)",
|
|
1713
1722
|
"validation.slotChainRequired": "Time-slot row has no models: add at least one chain entry",
|
|
1714
1723
|
"validation.slotWindow": "Time-slot start/end must use HH:mm format",
|
|
1715
1724
|
"validation.slotDays": "Days must be integers 0–6",
|
|
@@ -1973,8 +1982,10 @@ window.__ModuleLoader__.load({
|
|
|
1973
1982
|
* implemented without a store change); on a first mount the latch is false,
|
|
1974
1983
|
* so the healthy card starts (and stays) collapsed through its first load.
|
|
1975
1984
|
*/
|
|
1976
|
-
const
|
|
1977
|
-
|
|
1985
|
+
const ALL_DAY_LABEL_KEYS = ["allDay.flash", "allDay.pro"];
|
|
1986
|
+
/** Optional caveat suffix per option (parallel to OFFICIAL_ALL_DAY_IDS):
|
|
1987
|
+
* Pro's radio is always disabled — its model is not yet in the catalog. */
|
|
1988
|
+
const ALL_DAY_CAVEAT_KEYS = [void 0, "allDay.proCaveat"];
|
|
1978
1989
|
const SLOT_PRESET_IDS = [
|
|
1979
1990
|
"liang-peak",
|
|
1980
1991
|
"liang-valley",
|
|
@@ -2027,19 +2038,19 @@ window.__ModuleLoader__.load({
|
|
|
2027
2038
|
/** Strict 24h `HH:mm` — the resolver's HHMM_RE twin (drift-guarded by the gateway reject-on-save). */
|
|
2028
2039
|
const HHMM_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
2029
2040
|
/**
|
|
2030
|
-
* The 默认模型 value for a chain: the official
|
|
2031
|
-
* is that model (Flash
|
|
2041
|
+
* The 默认模型 value for a chain: the official id when the chain TAIL
|
|
2042
|
+
* is that model (Flash or Pro XOR — leading 默认降级链 entries allowed);
|
|
2032
2043
|
* `''` for an empty chain or a chain whose last entry is not official
|
|
2033
2044
|
* (the panel reads back unselected and save validation blocks the value).
|
|
2034
2045
|
*/
|
|
2035
2046
|
function allDayModelOf(chain) {
|
|
2036
2047
|
const tail = chain.length >= 1 ? chain[chain.length - 1] : void 0;
|
|
2037
|
-
return tail
|
|
2048
|
+
return tail !== void 0 && isOfficialAllDayId(tail) ? tail : "";
|
|
2038
2049
|
}
|
|
2039
2050
|
/**
|
|
2040
|
-
* The 默认降级链 editor row: the leading entries BEFORE the official
|
|
2041
|
-
*
|
|
2042
|
-
* rides the accepted value until a 默认模型 pick).
|
|
2051
|
+
* The 默认降级链 editor row: the leading entries BEFORE the official tail
|
|
2052
|
+
* (Flash or Pro), or the whole chain while the tail is not official (the
|
|
2053
|
+
* draft rides the accepted value until a 默认模型 pick).
|
|
2043
2054
|
*/
|
|
2044
2055
|
function allDayChainRowOf(chain, catalog) {
|
|
2045
2056
|
return rootChainToRows(allDayModelOf(chain) === "" ? chain : chain.slice(0, -1), catalog)[0];
|
|
@@ -2074,8 +2085,8 @@ window.__ModuleLoader__.load({
|
|
|
2074
2085
|
* (AC-7 re-scope, PM decision 2026-08-17 Option A).
|
|
2075
2086
|
*
|
|
2076
2087
|
* All-day: rootChain is composed from the 默认降级链 editor's leading
|
|
2077
|
-
* selectors plus the 默认模型 tail (exactly one official
|
|
2078
|
-
* Pro). While no tail is selected the ACCEPTED chain rides through
|
|
2088
|
+
* selectors plus the 默认模型 tail (exactly one official model — Flash or
|
|
2089
|
+
* Pro XOR). While no tail is selected the ACCEPTED chain rides through
|
|
2079
2090
|
* untouched. `timeSlots` is rebuilt from the slot rows every render. `tz`
|
|
2080
2091
|
* is a card scalar: preset rows lock it to Asia/Shanghai; custom rows
|
|
2081
2092
|
* follow the selected timezone.
|
|
@@ -2165,7 +2176,7 @@ window.__ModuleLoader__.load({
|
|
|
2165
2176
|
if ((role.chain ?? []).length === 0 && !seedInfo.has(role.id.trim())) errors.sub.push(t("validation.roleChainRequired", { id: role.id }));
|
|
2166
2177
|
}
|
|
2167
2178
|
const allDayTail = draft.rootChain.length >= 1 ? draft.rootChain[draft.rootChain.length - 1] : void 0;
|
|
2168
|
-
if (allDayTail
|
|
2179
|
+
if (allDayTail === void 0 || !isOfficialAllDayId(allDayTail)) errors.main.push(t("validation.allDayRequired"));
|
|
2169
2180
|
for (const entry of draft.rootChain) try {
|
|
2170
2181
|
parseSelector(entry);
|
|
2171
2182
|
} catch (error) {
|
|
@@ -3448,36 +3459,28 @@ window.__ModuleLoader__.load({
|
|
|
3448
3459
|
className: FallbacksCard_module_css_default.list,
|
|
3449
3460
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
3450
3461
|
className: FallbacksCard_module_css_default.editorCard,
|
|
3451
|
-
children: [
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
type: "radio",
|
|
3456
|
-
name: "fallbacks-all-day",
|
|
3457
|
-
checked: allDayModel === ALL_DAY_FLASH,
|
|
3458
|
-
disabled: !writable,
|
|
3459
|
-
onChange: () => {
|
|
3460
|
-
setAllDayModel(ALL_DAY_FLASH);
|
|
3461
|
-
}
|
|
3462
|
-
}), t("allDay.flash")]
|
|
3463
|
-
}),
|
|
3464
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
3465
|
-
className: FallbacksCard_module_css_default.optionRow,
|
|
3466
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
3462
|
+
children: [OFFICIAL_ALL_DAY_IDS.map((id, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
3463
|
+
className: FallbacksCard_module_css_default.optionRow,
|
|
3464
|
+
children: [
|
|
3465
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
3467
3466
|
type: "radio",
|
|
3468
3467
|
name: "fallbacks-all-day",
|
|
3469
|
-
checked: allDayModel ===
|
|
3470
|
-
disabled: !writable,
|
|
3468
|
+
checked: allDayModel === id,
|
|
3469
|
+
disabled: !writable || id === "deepseek-official/deepseek-pro",
|
|
3471
3470
|
onChange: () => {
|
|
3472
|
-
setAllDayModel(
|
|
3471
|
+
setAllDayModel(id);
|
|
3473
3472
|
}
|
|
3474
|
-
}),
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3473
|
+
}),
|
|
3474
|
+
t(ALL_DAY_LABEL_KEYS[index]),
|
|
3475
|
+
ALL_DAY_CAVEAT_KEYS[index] !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
3476
|
+
className: FallbacksCard_module_css_default.hint,
|
|
3477
|
+
children: t(ALL_DAY_CAVEAT_KEYS[index])
|
|
3478
|
+
})
|
|
3479
|
+
]
|
|
3480
|
+
}, id)), allDayModel === "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
3481
|
+
className: FallbacksCard_module_css_default.hint,
|
|
3482
|
+
children: t("allDay.nonconforming")
|
|
3483
|
+
})]
|
|
3481
3484
|
})
|
|
3482
3485
|
})
|
|
3483
3486
|
]
|
package/dist/client/locales.d.ts
CHANGED
|
@@ -102,6 +102,7 @@ export declare const zh: {
|
|
|
102
102
|
'allDay.hint': string;
|
|
103
103
|
'allDay.flash': string;
|
|
104
104
|
'allDay.pro': string;
|
|
105
|
+
'allDay.proCaveat': string;
|
|
105
106
|
'allDay.nonconforming': string;
|
|
106
107
|
'roles.list.label': string;
|
|
107
108
|
'roles.list.hint': string;
|
|
@@ -299,6 +300,7 @@ export declare const en: {
|
|
|
299
300
|
'allDay.hint': string;
|
|
300
301
|
'allDay.flash': string;
|
|
301
302
|
'allDay.pro': string;
|
|
303
|
+
'allDay.proCaveat': string;
|
|
302
304
|
'allDay.nonconforming': string;
|
|
303
305
|
'roles.list.label': string;
|
|
304
306
|
'roles.list.hint': string;
|
package/dist/events.d.ts
CHANGED
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
* Writing is stopped (issue #52): the plugin no longer appends this event —
|
|
5
5
|
* the apply()-time event-type registration was proven ineffective (module
|
|
6
6
|
* instance mismatch), and a session containing the event refused to load
|
|
7
|
-
* after a dsh restart. The event now exists only in history: old logs
|
|
8
|
-
* repaired by `
|
|
9
|
-
*
|
|
7
|
+
* after a dsh restart. The event now exists only in history: old logs that
|
|
8
|
+
* contain it cannot be repaired by an `ignorable` flag (the released
|
|
9
|
+
* session-format chain refuses unknown event types even when marked
|
|
10
|
+
* ignorable), so `scripts/repair-fallbacks-switch-logs.ts` is a detector
|
|
11
|
+
* that reports such logs and never writes.
|
|
10
12
|
* The module is type-only — the augmentation is erased at runtime; the
|
|
11
13
|
* plugin's runtime behavior lives in `src/index.ts`.
|
|
12
14
|
*
|
package/dist/index.js
CHANGED
|
@@ -34,6 +34,9 @@ function resolveWildcardEntry(failingModel, provider) {
|
|
|
34
34
|
raw: `${provider}/${failingModel}`
|
|
35
35
|
};
|
|
36
36
|
}
|
|
37
|
+
function isOfficialAllDayId(id) {
|
|
38
|
+
return id === "deepseek-official/deepseek-flash" || id === "deepseek-official/deepseek-pro";
|
|
39
|
+
}
|
|
37
40
|
const PRESETS = {
|
|
38
41
|
"liang-peak": {
|
|
39
42
|
windows: [{
|
|
@@ -220,7 +223,7 @@ function labelOf(row) {
|
|
|
220
223
|
function isAllDayConforming(chain) {
|
|
221
224
|
if (chain.length < 1) return false;
|
|
222
225
|
const tail = chain[chain.length - 1];
|
|
223
|
-
return tail
|
|
226
|
+
return isOfficialAllDayId(tail);
|
|
224
227
|
}
|
|
225
228
|
function resolveEffectiveChain(config, now, tz) {
|
|
226
229
|
const state = resolveSlotState(config, now, tz);
|
|
@@ -359,7 +362,7 @@ function validateFallbacksConfig(config, logger) {
|
|
|
359
362
|
if ((role.chain ?? []).length === 0) logger.warn(`llm-fallbacks: role "${role.id}" has no model config — declare at least one chain entry, or use the built-in "inherit" rule target instead`);
|
|
360
363
|
if (role.fallback !== void 0 && role.fallback !== "inherit-root" && role.fallback !== "none") logger.warn(`llm-fallbacks: role "${role.id}" has invalid fallback "${String(role.fallback)}" — expected "inherit-root" or "none"`);
|
|
361
364
|
}
|
|
362
|
-
if (config.rootChain.length > 0 && !isAllDayConforming(config.rootChain)) logger.warn("llm-fallbacks: rootChain must end with exactly one official
|
|
365
|
+
if (config.rootChain.length > 0 && !isAllDayConforming(config.rootChain)) logger.warn("llm-fallbacks: rootChain must end with exactly one official model (deepseek-official/deepseek-flash or deepseek-official/deepseek-pro) — time-slot rows and the virtual picker row stay inert until the all-day chain tail conforms");
|
|
363
366
|
for (const entry of config.rootChain) try {
|
|
364
367
|
parseSelector(entry);
|
|
365
368
|
} catch (error) {
|
|
@@ -1160,7 +1163,7 @@ function validateConfigPatch(patch) {
|
|
|
1160
1163
|
}
|
|
1161
1164
|
if (key === "rootChain") {
|
|
1162
1165
|
const chain = patch[key];
|
|
1163
|
-
if (chain !== null && chain !== void 0 && (!Array.isArray(chain) || !isAllDayConforming(chain))) throw new Error("dsh-llm-fallbacks: rootChain must end with exactly one official
|
|
1166
|
+
if (chain !== null && chain !== void 0 && (!Array.isArray(chain) || !isAllDayConforming(chain))) throw new Error("dsh-llm-fallbacks: rootChain must end with exactly one official model (deepseek-official/deepseek-flash or deepseek-official/deepseek-pro)");
|
|
1164
1167
|
}
|
|
1165
1168
|
}
|
|
1166
1169
|
Config(patch);
|
|
@@ -2058,10 +2061,10 @@ function buildFallbacksTuiSection() {
|
|
|
2058
2061
|
zh: "根降级链(JSON)",
|
|
2059
2062
|
en: "Root chain (JSON)"
|
|
2060
2063
|
},
|
|
2061
|
-
hint: "JSON array of model selectors; the last entry must be an official
|
|
2064
|
+
hint: "JSON array of model selectors; the last entry must be an official model (deepseek-official/deepseek-flash or deepseek-official/deepseek-pro). Pro is a legal tail but not yet available in the catalog — the settings card shows it disabled.",
|
|
2062
2065
|
hintDescriptions: {
|
|
2063
|
-
zh: "模型选择器 JSON
|
|
2064
|
-
en: "JSON array of model selectors; the last entry must be an official
|
|
2066
|
+
zh: "模型选择器 JSON 数组;末项必须是官方模型(deepseek-official/deepseek-flash 或 deepseek-official/deepseek-pro)。Pro 是合法链尾但目录暂未提供——设置卡中显示为禁用。",
|
|
2067
|
+
en: "JSON array of model selectors; the last entry must be an official model (deepseek-official/deepseek-flash or deepseek-official/deepseek-pro). Pro is a legal tail but not yet available in the catalog — the settings card shows it disabled."
|
|
2065
2068
|
},
|
|
2066
2069
|
group: "chain",
|
|
2067
2070
|
kind: "text",
|
|
@@ -2704,7 +2707,7 @@ function apply(ctx, config = defaultFallbacksConfig) {
|
|
|
2704
2707
|
});
|
|
2705
2708
|
}
|
|
2706
2709
|
if (config.enabled && seed.provider === "FallbacksChain" && seed.model === "Auto" && agent.session?.header?.origin !== "subagent") {
|
|
2707
|
-
if (!isAllDayConforming(config.rootChain)) logger.warn("llm-fallbacks: FallbacksChain/Auto selected but the all-day rootChain is not conforming (exactly one official
|
|
2710
|
+
if (!isAllDayConforming(config.rootChain)) logger.warn("llm-fallbacks: FallbacksChain/Auto selected but the all-day rootChain is not conforming (exactly one official model: deepseek-official/deepseek-flash or deepseek-official/deepseek-pro) — no primary override");
|
|
2708
2711
|
else {
|
|
2709
2712
|
const head = firstDispatchableExactHead(resolveEffectiveChain(config, new Date(), config.tz ?? "Asia/Shanghai"));
|
|
2710
2713
|
if (head === void 0) logger.warn("llm-fallbacks: FallbacksChain/Auto selected but the effective chain has no exact head (empty, wildcard-only, or self-route) — no primary override");
|
package/dist/time-slots.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Time-slot rows for `fallbacks` (plan fallbacks-timeslots Task 1, pins
|
|
3
|
-
* P4–P6): frozen preset windows, the official
|
|
3
|
+
* P4–P6): frozen preset windows, the official all-day conformance
|
|
4
4
|
* guard, and the pure `resolveEffectiveChain` / `resolveSlotState`
|
|
5
5
|
* resolver.
|
|
6
6
|
*
|
|
@@ -15,9 +15,17 @@
|
|
|
15
15
|
* @module dsh-llm-fallbacks/time-slots
|
|
16
16
|
*/
|
|
17
17
|
import type { FallbacksConfig } from './config.ts';
|
|
18
|
-
/** Official
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
/** Official all-day selectors — the ONLY legal tails (length 1, XOR):
|
|
19
|
+
* Flash (the current official default, selectable) and Pro (displayed but
|
|
20
|
+
* disabled — its model is not yet served by the 0.1.5-rc.1 catalog). The
|
|
21
|
+
* retired V4 ids (`deepseek-v4-flash` / `deepseek-v4-pro`) are no longer
|
|
22
|
+
* legal tails. */
|
|
23
|
+
export declare const OFFICIAL_FLASH = "deepseek-official/deepseek-flash";
|
|
24
|
+
export declare const OFFICIAL_PRO = "deepseek-official/deepseek-pro";
|
|
25
|
+
/** The two official all-day tail ids (XOR — exactly one legal tail). */
|
|
26
|
+
export declare const OFFICIAL_ALL_DAY_IDS: readonly ["deepseek-official/deepseek-flash", "deepseek-official/deepseek-pro"];
|
|
27
|
+
/** Whether `id` is one of the two official all-day tail ids. */
|
|
28
|
+
export declare function isOfficialAllDayId(id: string): boolean;
|
|
21
29
|
/** The four frozen preset ids (exact strings, spec lock). */
|
|
22
30
|
export declare const PRESET_IDS: readonly ["liang-peak", "liang-valley", "glm-peak", "glm-valley"];
|
|
23
31
|
export type PresetId = (typeof PRESET_IDS)[number];
|
|
@@ -65,12 +73,13 @@ export interface PresetDefinition {
|
|
|
65
73
|
export declare const PRESETS: Record<PresetId, PresetDefinition>;
|
|
66
74
|
/**
|
|
67
75
|
* All-day conformance (P6): the all-day chain is conforming when its LAST
|
|
68
|
-
* entry (the tail — the card's 默认模型 panel) is exactly one official
|
|
69
|
-
* model — Flash
|
|
70
|
-
* the ordered walk before that last-resort fallback. An empty chain or
|
|
71
|
-
* chain whose tail is not an official
|
|
72
|
-
*
|
|
73
|
-
* the raw chain stays
|
|
76
|
+
* entry (the tail — the card's 默认模型 panel) is exactly one official
|
|
77
|
+
* model — Flash or Pro (XOR). Leading entries (the card's 默认降级链 block)
|
|
78
|
+
* are the ordered walk before that last-resort fallback. An empty chain or
|
|
79
|
+
* a chain whose tail is not an official model (including the retired V4
|
|
80
|
+
* ids) keeps slot rows inert and refuses the virtual-row
|
|
81
|
+
* override/delegate; the v0.2.2 failure walk over the raw chain stays
|
|
82
|
+
* verbatim.
|
|
74
83
|
*/
|
|
75
84
|
export declare function isAllDayConforming(chain: readonly string[]): boolean;
|
|
76
85
|
/**
|
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"dsh",
|
|
6
6
|
"dsh-plugin"
|
|
7
7
|
],
|
|
8
|
-
"version": "0.4.
|
|
8
|
+
"version": "0.4.4",
|
|
9
9
|
"type": "module",
|
|
10
10
|
"main": "./dist/index.js",
|
|
11
11
|
"types": "./dist/index.d.ts",
|
|
@@ -60,45 +60,65 @@
|
|
|
60
60
|
},
|
|
61
61
|
"peerDependencies": {
|
|
62
62
|
"@deepseek-ai/cordis": "^4.0.2",
|
|
63
|
-
"@deepseek-ai/dsh-agent": "^0.1.
|
|
64
|
-
"@deepseek-ai/dsh-api-gateway": "^0.1.
|
|
65
|
-
"@deepseek-ai/dsh-api-remotes": "^0.1.
|
|
66
|
-
"@deepseek-ai/dsh-api-session-controller": "^0.1.
|
|
67
|
-
"@deepseek-ai/dsh-client-connection": "^0.1.
|
|
68
|
-
"@deepseek-ai/dsh-client-locale": "^0.1.
|
|
69
|
-
"@deepseek-ai/dsh-client-store": "^0.1.
|
|
70
|
-
"@deepseek-ai/dsh-client-ui-chat": "^0.1.
|
|
71
|
-
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.
|
|
72
|
-
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.
|
|
73
|
-
"@deepseek-ai/dsh-client-ui-renderer": "^0.1.
|
|
74
|
-
"@deepseek-ai/dsh-client-ui-settings": "^0.1.
|
|
75
|
-
"@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.
|
|
76
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.
|
|
77
|
-
"@deepseek-ai/dsh-commands": "^0.1.
|
|
78
|
-
"@deepseek-ai/dsh-llm": "^0.1.
|
|
79
|
-
"@deepseek-ai/dsh-llm-retry": "^0.1.
|
|
80
|
-
"@deepseek-ai/dsh-session": "^0.1.
|
|
81
|
-
"@deepseek-ai/dsh-settings": "^0.1.
|
|
82
|
-
"@deepseek-ai/dsh-tool-subagent": "^0.1.
|
|
83
|
-
"@deepseek-ai/dsh-typert-protocol": "^0.1.
|
|
84
|
-
"@deepseek-ai/dsh-typert-registry": "^0.1.
|
|
63
|
+
"@deepseek-ai/dsh-agent": "^0.1.5-rc.1",
|
|
64
|
+
"@deepseek-ai/dsh-api-gateway": "^0.1.5-rc.1",
|
|
65
|
+
"@deepseek-ai/dsh-api-remotes": "^0.1.5-rc.1",
|
|
66
|
+
"@deepseek-ai/dsh-api-session-controller": "^0.1.5-rc.1",
|
|
67
|
+
"@deepseek-ai/dsh-client-connection": "^0.1.5-rc.1",
|
|
68
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.5-rc.1",
|
|
69
|
+
"@deepseek-ai/dsh-client-store": "^0.1.5-rc.1",
|
|
70
|
+
"@deepseek-ai/dsh-client-ui-chat": "^0.1.5-rc.1",
|
|
71
|
+
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.5-rc.1",
|
|
72
|
+
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.5-rc.1",
|
|
73
|
+
"@deepseek-ai/dsh-client-ui-renderer": "^0.1.5-rc.1",
|
|
74
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.1.5-rc.1",
|
|
75
|
+
"@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.5-rc.1",
|
|
76
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.1",
|
|
77
|
+
"@deepseek-ai/dsh-commands": "^0.1.5-rc.1",
|
|
78
|
+
"@deepseek-ai/dsh-llm": "^0.1.5-rc.1",
|
|
79
|
+
"@deepseek-ai/dsh-llm-retry": "^0.1.5-rc.1",
|
|
80
|
+
"@deepseek-ai/dsh-session": "^0.1.5-rc.1",
|
|
81
|
+
"@deepseek-ai/dsh-settings": "^0.1.5-rc.1",
|
|
82
|
+
"@deepseek-ai/dsh-tool-subagent": "^0.1.5-rc.1",
|
|
83
|
+
"@deepseek-ai/dsh-typert-protocol": "^0.1.5-rc.1",
|
|
84
|
+
"@deepseek-ai/dsh-typert-registry": "^0.1.5-rc.1",
|
|
85
85
|
"@deepseek-ai/schemastery": "^3.18.2",
|
|
86
86
|
"react": "^18.2.0"
|
|
87
87
|
},
|
|
88
88
|
"devDependencies": {
|
|
89
|
+
"@shikijs/langs": "^4.4.3",
|
|
89
90
|
"@testing-library/dom": "^10.4.1",
|
|
90
91
|
"@testing-library/react": "^16.3.2",
|
|
92
|
+
"@types/mdast": "^4.0.4",
|
|
91
93
|
"@types/node": "^22.20.0",
|
|
92
94
|
"@types/react": "~18.3.31",
|
|
93
95
|
"@types/react-dom": "~18.3.7",
|
|
94
96
|
"@types/use-sync-external-store": "^1.5.0",
|
|
97
|
+
"anser": "^2.3.5",
|
|
98
|
+
"clsx": "^2.1.1",
|
|
99
|
+
"immer": "^10.2.0",
|
|
95
100
|
"jsdom": "29.1.1",
|
|
101
|
+
"katex": "^0.16.47",
|
|
102
|
+
"mdast-util-from-markdown": "^2.0.3",
|
|
103
|
+
"mdast-util-gfm": "^3.1.0",
|
|
104
|
+
"mdast-util-math": "^3.0.0",
|
|
105
|
+
"micromark-core-commonmark": "^2.0.3",
|
|
106
|
+
"micromark-extension-gfm": "^3.0.0",
|
|
107
|
+
"micromark-extension-math": "^3.1.0",
|
|
108
|
+
"micromark-factory-space": "^2.0.1",
|
|
109
|
+
"micromark-util-character": "^2.1.1",
|
|
110
|
+
"micromark-util-classify-character": "^2.0.1",
|
|
111
|
+
"micromark-util-sanitize-uri": "^2.0.1",
|
|
112
|
+
"micromark-util-symbol": "^2.0.1",
|
|
113
|
+
"micromark-util-types": "^2.0.2",
|
|
96
114
|
"react": "^18.3.1",
|
|
97
115
|
"react-dom": "^18.3.1",
|
|
116
|
+
"shiki": "^4.4.3",
|
|
98
117
|
"tsdown": "^0.22.2",
|
|
99
118
|
"tsx": "^4.22.4",
|
|
100
119
|
"typescript": "^5.9.3",
|
|
101
|
-
"vitest": "^4.1.8"
|
|
120
|
+
"vitest": "^4.1.8",
|
|
121
|
+
"zustand": "~4.4.7"
|
|
102
122
|
},
|
|
103
123
|
"repository": {
|
|
104
124
|
"type": "git",
|
package/scripts/build-client.ts
CHANGED
|
@@ -48,11 +48,15 @@ const OUT_FILE = 'index.js'
|
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
50
|
* Frozen loader module table (dsh mechanism-guide): the plan-documented
|
|
51
|
-
* composition = PLATFORM_MODULES (
|
|
52
|
-
* — the old runtime `./client` exemption became
|
|
53
|
-
* `@deepseek-ai/dsh-client-store` platform module, so the store VALUE
|
|
54
|
-
* stays external and resolves from the host loader table) + the
|
|
55
|
-
* PRELOADED_CLIENT_EXTERNALS (empty in
|
|
51
|
+
* composition = PLATFORM_MODULES (9 entries in 0.1.5-rc.1,
|
|
52
|
+
* `web/src/platform.ts:8-16` — the old runtime `./client` exemption became
|
|
53
|
+
* the `@deepseek-ai/dsh-client-store` platform module, so the store VALUE
|
|
54
|
+
* import stays external and resolves from the host loader table) + the
|
|
55
|
+
* host's PRELOADED_CLIENT_EXTERNALS (empty in `packages/client/web/src/platform.ts`).
|
|
56
|
+
* The table need not list
|
|
57
|
+
* every platform module — it lists what this plugin's client half resolves
|
|
58
|
+
* from the frozen loader table; the rc.1 addition
|
|
59
|
+
* `@deepseek-ai/dsh-client-ui-dockkit` is not imported by this plugin.
|
|
56
60
|
* rc.8 dropped `dsh-client-web-react` (deleted
|
|
57
61
|
* package — the uSES bridge is vendored in `src/client/use-snapshot.ts`),
|
|
58
62
|
* `dsh-client-ui-attachment` and `dsh-client-schema-form` from the platform
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* repair-fallbacks-switch-logs.ts —
|
|
2
|
+
* repair-fallbacks-switch-logs.ts — detect session logs poisoned by the old
|
|
3
3
|
* plugin's durable `fallbacks/switch` events.
|
|
4
4
|
*
|
|
5
5
|
* Background: until Task 1 of `fallbacks-session-event-stop-write`, the
|
|
@@ -8,60 +8,50 @@
|
|
|
8
8
|
* is outside its baked catalog unless the event carries `ignorable: true`.
|
|
9
9
|
* `Session.append` cannot write `ignorable`, and runtime registration proved
|
|
10
10
|
* ineffective (module-instance mismatch). Session logs written while the old
|
|
11
|
-
* plugin was active therefore fail to load after a dsh restart.
|
|
12
|
-
*
|
|
11
|
+
* plugin was active therefore fail to load after a dsh restart.
|
|
12
|
+
*
|
|
13
|
+
* FAIL-CLOSED (measured against the published 0.1.5-rc.1 packages): the
|
|
14
|
+
* released session-format migration chain refuses unknown event types even
|
|
15
|
+
* with `ignorable: true` — the v0→v1 stage's `assertReleasedEventPayload`
|
|
16
|
+
* throws `format v0 contains unknown historical event type
|
|
17
|
+
* "fallbacks/switch" ... migration refuses unknown historical events even
|
|
18
|
+
* when ignorable` before any later edge runs (v1→v2 refuses unknown types
|
|
19
|
+
* the same way). A log whose `fallbacks/switch` events are marked
|
|
20
|
+
* `ignorable` therefore still fails to load, so this script NEVER writes a
|
|
21
|
+
* "repaired" file and never reports a repair: it detects such logs, reports
|
|
22
|
+
* them, and exits non-zero. The durable fix belongs upstream at the
|
|
23
|
+
* migration edges, not in this script.
|
|
13
24
|
*
|
|
14
25
|
* Session log format (`~/.dsh/sessions/<namespace>/<session-id>/session.jsonl.zstd`):
|
|
15
26
|
* - concatenated-zstd-frame container: **first frame MUST decode to
|
|
16
|
-
* exactly one header line** (rc.
|
|
17
|
-
* `indexOf(10) === length-1`
|
|
27
|
+
* exactly one header line** (0.1.5-rc.1 `assertZstdHeaderFrame`:
|
|
28
|
+
* `indexOf(10) === length-1` — the framing invariant is unchanged
|
|
29
|
+
* since rc.7). Subsequent frames hold events.
|
|
18
30
|
* - `node:zlib.zstdDecompress` only decodes the FIRST frame, so this
|
|
19
31
|
* script shells out to the `zstd` CLI (`zstd -d -c` decodes every
|
|
20
|
-
* concatenated frame).
|
|
21
|
-
* + a following frame for the rest — a single-frame rewrite of the
|
|
22
|
-
* whole log fails host boot (`first frame is not exactly one header line`).
|
|
23
|
-
*
|
|
24
|
-
* IMPORTANT: stop dsh before running with `--apply`. The script replaces a
|
|
25
|
-
* session log via read → transform → atomic rename; a live dsh that appends
|
|
26
|
-
* a new zstd frame between the read and the rename would have that frame
|
|
27
|
-
* lost (read-modify-write race). Report / `--dry-run` runs are safe any
|
|
28
|
-
* time — they never write files.
|
|
32
|
+
* concatenated frame).
|
|
29
33
|
*
|
|
30
34
|
* Usage (from the repo root, via tsx — the package.json
|
|
31
35
|
* `repair:fallbacks-switch-logs` script):
|
|
32
36
|
* pnpm repair:fallbacks-switch-logs -- --dry-run
|
|
33
|
-
* pnpm repair:fallbacks-switch-logs -- --
|
|
34
|
-
* pnpm repair:fallbacks-switch-logs -- --root /tmp/repair-fixture --dry-run
|
|
37
|
+
* pnpm repair:fallbacks-switch-logs -- --root /tmp/repair-fixture
|
|
35
38
|
*
|
|
36
39
|
* Flags:
|
|
37
40
|
* --root <dir> session root to walk (default: ~/.dsh/sessions)
|
|
38
|
-
* --dry-run report only —
|
|
39
|
-
* --backup
|
|
40
|
-
*
|
|
41
|
-
* --apply
|
|
42
|
-
*
|
|
43
|
-
* reports would-change
|
|
41
|
+
* --dry-run report only (the only mode — no write is ever performed)
|
|
42
|
+
* --backup accepted for backward compatibility (no write is ever
|
|
43
|
+
* performed)
|
|
44
|
+
* --apply accepted for backward compatibility (no write is ever
|
|
45
|
+
* performed)
|
|
44
46
|
*
|
|
45
|
-
* Safe by construction:
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* reported and skipped (never corrupted); the replace is an atomic rename
|
|
49
|
-
* of a temp file in the same directory. Report/dry-run mode performs no
|
|
50
|
-
* filesystem writes at all.
|
|
47
|
+
* Safe by construction: the script never writes files. A log that fails to
|
|
48
|
+
* decompress is reported and skipped; a log containing `fallbacks/switch`
|
|
49
|
+
* events is reported as unrepairable and counts as an error (exit non-zero).
|
|
51
50
|
*/
|
|
52
51
|
import { execFileSync } from 'node:child_process'
|
|
53
|
-
import {
|
|
54
|
-
chmodSync,
|
|
55
|
-
copyFileSync,
|
|
56
|
-
existsSync,
|
|
57
|
-
readdirSync,
|
|
58
|
-
renameSync,
|
|
59
|
-
rmSync,
|
|
60
|
-
statSync,
|
|
61
|
-
writeFileSync,
|
|
62
|
-
} from 'node:fs'
|
|
52
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
63
53
|
import { homedir } from 'node:os'
|
|
64
|
-
import {
|
|
54
|
+
import { join } from 'node:path'
|
|
65
55
|
import { pathToFileURL } from 'node:url'
|
|
66
56
|
|
|
67
57
|
/** Decoded plaintext cap for one session log (512 MB; logs can be large). */
|
|
@@ -83,13 +73,17 @@ export interface MarkFallbacksSwitchIgnorableResult {
|
|
|
83
73
|
* - every other line (non-switch events, malformed JSON, empty lines, switch
|
|
84
74
|
* events that already carry `ignorable`) passes through byte-identical;
|
|
85
75
|
* - switch events are re-serialized with `ignorable` appended (insertion
|
|
86
|
-
* order — the read path only reads `event.ignorable`, so field
|
|
87
|
-
* irrelevant); `seq`/`time`/`data` are preserved verbatim;
|
|
76
|
+
* order — the released read path only reads `event.ignorable`, so field
|
|
77
|
+
* position is irrelevant); `seq`/`time`/`data` are preserved verbatim;
|
|
88
78
|
* - `changed` counts only lines that were modified.
|
|
89
79
|
*
|
|
90
80
|
* Matching is on the parsed `type` field, never on the raw string — the
|
|
91
81
|
* substring `fallbacks/switch` legitimately appears inside user/message data
|
|
92
82
|
* and must not be treated as an event.
|
|
83
|
+
*
|
|
84
|
+
* NOTE: this transform is retained as the pure boundary the regression tests
|
|
85
|
+
* pin, but its output is REFUSED by the released session-format chain (see
|
|
86
|
+
* the module docblock) — the CLI never writes it.
|
|
93
87
|
*/
|
|
94
88
|
export function markFallbacksSwitchIgnorable(lines: string[]): MarkFallbacksSwitchIgnorableResult {
|
|
95
89
|
const out = new Array<string>(lines.length)
|
|
@@ -130,26 +124,54 @@ export function markFallbacksSwitchIgnorable(lines: string[]): MarkFallbacksSwit
|
|
|
130
124
|
return { lines: out, changed }
|
|
131
125
|
}
|
|
132
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Count every parsed `fallbacks/switch` row in a log, regardless of whether
|
|
129
|
+
* it already carries an `ignorable` field or what its value is. The released
|
|
130
|
+
* session-format chain refuses the unknown event type even when `ignorable`
|
|
131
|
+
* is present, so ANY such row makes the log unrepairable — this is the
|
|
132
|
+
* fail-closed detection, separate from the pure transform's `changed` count
|
|
133
|
+
* (which only counts rows the transform would modify).
|
|
134
|
+
*
|
|
135
|
+
* Detection is valid-JSON-only: an unparseable line (e.g. a truncated row)
|
|
136
|
+
* is skipped, so a log whose switch row is itself malformed classifies as
|
|
137
|
+
* `unchanged` — the raw-substring alternative is deliberately rejected
|
|
138
|
+
* because `fallbacks/switch` legitimately appears inside user data.
|
|
139
|
+
*/
|
|
140
|
+
export function countFallbacksSwitchRows(lines: string[]): number {
|
|
141
|
+
let count = 0
|
|
142
|
+
for (const line of lines) {
|
|
143
|
+
if (line === '') continue
|
|
144
|
+
let parsed: unknown
|
|
145
|
+
try {
|
|
146
|
+
parsed = JSON.parse(line)
|
|
147
|
+
} catch {
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
if (parsed === null || typeof parsed !== 'object') continue
|
|
151
|
+
if ((parsed as Record<string, unknown>).type === SWITCH_TYPE) count++
|
|
152
|
+
}
|
|
153
|
+
return count
|
|
154
|
+
}
|
|
155
|
+
|
|
133
156
|
/* ------------------------------------------------------------------ */
|
|
134
157
|
/* CLI */
|
|
135
158
|
/* ------------------------------------------------------------------ */
|
|
136
159
|
|
|
137
160
|
interface CliOptions {
|
|
138
161
|
root: string
|
|
139
|
-
dryRun: boolean
|
|
140
|
-
backup: boolean
|
|
141
|
-
apply: boolean
|
|
142
162
|
}
|
|
143
163
|
|
|
144
164
|
function usage(): string {
|
|
145
165
|
return `usage: tsx scripts/repair-fallbacks-switch-logs.ts [--root DIR] [--dry-run] [--backup] [--apply]
|
|
146
166
|
|
|
147
167
|
--root DIR session root to walk (default: ~/.dsh/sessions)
|
|
148
|
-
--dry-run report only —
|
|
149
|
-
--backup
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
168
|
+
--dry-run report only (the only mode — no write is ever performed)
|
|
169
|
+
--backup accepted for backward compatibility (no write is ever performed)
|
|
170
|
+
--apply accepted for backward compatibility (no write is ever performed)
|
|
171
|
+
|
|
172
|
+
The released session-format chain (v0→v1) refuses unknown event types even
|
|
173
|
+
when marked ignorable, so this script fails closed: it only reports logs
|
|
174
|
+
that contain fallbacks/switch events and never writes a "repaired" file.`
|
|
153
175
|
}
|
|
154
176
|
|
|
155
177
|
function expandHome(p: string): string {
|
|
@@ -159,9 +181,6 @@ function expandHome(p: string): string {
|
|
|
159
181
|
|
|
160
182
|
export function parseArgs(argv: string[]): CliOptions {
|
|
161
183
|
let root = join(homedir(), '.dsh', 'sessions')
|
|
162
|
-
let dryRun = false
|
|
163
|
-
let backup = false
|
|
164
|
-
let apply = false
|
|
165
184
|
for (let i = 0; i < argv.length; i++) {
|
|
166
185
|
const arg = argv[i]
|
|
167
186
|
// pnpm run <script> -- <args> forwards the separator `--`; skip it.
|
|
@@ -173,24 +192,16 @@ export function parseArgs(argv: string[]): CliOptions {
|
|
|
173
192
|
root = expandHome(argv[i])
|
|
174
193
|
break
|
|
175
194
|
case '--dry-run':
|
|
176
|
-
dryRun = true
|
|
177
|
-
break
|
|
178
195
|
case '--backup':
|
|
179
|
-
backup = true
|
|
180
|
-
break
|
|
181
196
|
case '--apply':
|
|
182
|
-
|
|
197
|
+
// Legacy mutation-era flags: accepted as no-ops — the tool never
|
|
198
|
+
// writes, so no flag controls a write or a precondition.
|
|
183
199
|
break
|
|
184
200
|
default:
|
|
185
201
|
throw new Error(`${usage()}\n\nunknown argument: ${arg}`)
|
|
186
202
|
}
|
|
187
203
|
}
|
|
188
|
-
|
|
189
|
-
throw new Error(
|
|
190
|
-
`${usage()}\n\n--apply requires --backup: real modification must keep a .bak copy of every replaced session log (run with --dry-run first to review)`,
|
|
191
|
-
)
|
|
192
|
-
}
|
|
193
|
-
return { root, dryRun, backup, apply }
|
|
204
|
+
return { root }
|
|
194
205
|
}
|
|
195
206
|
|
|
196
207
|
/** Resolve the zstd CLI binary; throw a clear error when it is missing. */
|
|
@@ -231,37 +242,16 @@ function findSessionLogs(root: string): string[] {
|
|
|
231
242
|
|
|
232
243
|
type FileOutcome =
|
|
233
244
|
| { action: 'unchanged'; changed: 0 }
|
|
234
|
-
| { action: '
|
|
235
|
-
| { action: 'changed'; changed: number }
|
|
245
|
+
| { action: 'refused'; changed: number; error: string }
|
|
236
246
|
| { action: 'error'; changed: number; error: string }
|
|
237
247
|
|
|
238
248
|
/**
|
|
239
|
-
*
|
|
240
|
-
*
|
|
249
|
+
* Decompress one log and classify it: `unchanged` when it carries no
|
|
250
|
+
* `fallbacks/switch` rows, `refused` when it does (the released chain
|
|
251
|
+
* rejects the unknown event type even when `ignorable` is present — see the
|
|
252
|
+
* module docblock), or `error` when it cannot be decompressed. Never writes.
|
|
241
253
|
*/
|
|
242
|
-
export function
|
|
243
|
-
const header = lines[0] ?? ''
|
|
244
|
-
const headerPlain = header.endsWith('\n') ? header : `${header}\n`
|
|
245
|
-
const zstdOut = { input: headerPlain, maxBuffer: MAX_BUFFER }
|
|
246
|
-
const frame1 = execFileSync(zstd, ['-c'], zstdOut)
|
|
247
|
-
const rest = lines.slice(1)
|
|
248
|
-
if (rest.length === 0 || (rest.length === 1 && rest[0] === '')) return frame1
|
|
249
|
-
const restPlain = rest.join('\n')
|
|
250
|
-
const frame2 = execFileSync(zstd, ['-c'], {
|
|
251
|
-
input: restPlain.endsWith('\n') ? restPlain : `${restPlain}\n`,
|
|
252
|
-
maxBuffer: MAX_BUFFER,
|
|
253
|
-
})
|
|
254
|
-
return Buffer.concat([frame1, frame2])
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/**
|
|
258
|
-
* Decompress all frames of one log, transform it, and (only when actually
|
|
259
|
-
* applying) atomically replace it with a two-frame re-encode (header frame
|
|
260
|
-
* + events frame) that passed `zstd -t` and carries the original file's
|
|
261
|
-
* permission bits. Report/dry-run returns would-change WITHOUT touching the
|
|
262
|
-
* filesystem.
|
|
263
|
-
*/
|
|
264
|
-
export function processFile(zstd: string, file: string, opts: CliOptions): FileOutcome {
|
|
254
|
+
export function processFile(zstd: string, file: string, _opts: CliOptions): FileOutcome {
|
|
265
255
|
let plain: string
|
|
266
256
|
try {
|
|
267
257
|
plain = execFileSync(zstd, ['-d', '-c', file], { encoding: 'utf8', maxBuffer: MAX_BUFFER })
|
|
@@ -273,36 +263,20 @@ export function processFile(zstd: string, file: string, opts: CliOptions): FileO
|
|
|
273
263
|
}
|
|
274
264
|
}
|
|
275
265
|
|
|
276
|
-
const
|
|
277
|
-
if (
|
|
266
|
+
const switchRows = countFallbacksSwitchRows(plain.split('\n'))
|
|
267
|
+
if (switchRows === 0) return { action: 'unchanged', changed: 0 }
|
|
278
268
|
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
const mode = statSync(file).mode & 0o7777
|
|
290
|
-
try {
|
|
291
|
-
writeFileSync(tmpZstd, encodeRepairedSessionLog(zstd, lines))
|
|
292
|
-
execFileSync(zstd, ['-t', tmpZstd], { stdio: 'ignore' })
|
|
293
|
-
copyFileSync(file, `${file}.bak`)
|
|
294
|
-
chmodSync(tmpZstd, mode)
|
|
295
|
-
renameSync(tmpZstd, file)
|
|
296
|
-
} catch (err) {
|
|
297
|
-
return {
|
|
298
|
-
action: 'error',
|
|
299
|
-
changed,
|
|
300
|
-
error: `re-encode/validate failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
301
|
-
}
|
|
302
|
-
} finally {
|
|
303
|
-
rmSync(tmpZstd, { force: true })
|
|
269
|
+
// Fail closed (session format v3 — measured against the published
|
|
270
|
+
// 0.1.5-rc.1 packages): the released migration chain refuses unknown
|
|
271
|
+
// event types even with `ignorable: true` (the v0→v1 stage throws before
|
|
272
|
+
// any later edge runs), so a "repaired" log would still be rejected on
|
|
273
|
+
// load. Never report it as a repair and never write it.
|
|
274
|
+
return {
|
|
275
|
+
action: 'refused',
|
|
276
|
+
changed: switchRows,
|
|
277
|
+
error:
|
|
278
|
+
'fallbacks/switch events cannot be repaired by an ignorable flag: the released session-format v0→v1 migration refuses unknown event types even when ignorable (session format v3)',
|
|
304
279
|
}
|
|
305
|
-
return { action: 'changed', changed }
|
|
306
280
|
}
|
|
307
281
|
|
|
308
282
|
export function main(): void {
|
|
@@ -319,11 +293,10 @@ export function main(): void {
|
|
|
319
293
|
return
|
|
320
294
|
}
|
|
321
295
|
|
|
322
|
-
const mode = opts.dryRun ? 'dry-run' : opts.apply ? 'apply' : 'report'
|
|
323
296
|
console.log(`root: ${opts.root}`)
|
|
324
|
-
console.log(`mode: ${mode}${opts.backup ? ' (backup .bak before replace)' : ''}`)
|
|
325
297
|
console.log(`zstd: ${zstd}`)
|
|
326
298
|
console.log(`files: ${files.length}`)
|
|
299
|
+
console.log('mode: report only — no write is ever performed (the released session-format chain refuses ignorable-marked unknown events)')
|
|
327
300
|
|
|
328
301
|
let totalFiles = 0
|
|
329
302
|
let totalEvents = 0
|
|
@@ -336,15 +309,11 @@ export function main(): void {
|
|
|
336
309
|
skipped++
|
|
337
310
|
console.log(` unchanged ${file}`)
|
|
338
311
|
break
|
|
339
|
-
case '
|
|
312
|
+
case 'refused':
|
|
340
313
|
totalFiles++
|
|
341
314
|
totalEvents += outcome.changed
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
case 'changed':
|
|
345
|
-
totalFiles++
|
|
346
|
-
totalEvents += outcome.changed
|
|
347
|
-
console.log(` changed ${outcome.changed} ${file}`)
|
|
315
|
+
errors++
|
|
316
|
+
console.error(` refused ${file}: ${outcome.error}`)
|
|
348
317
|
break
|
|
349
318
|
case 'error':
|
|
350
319
|
errors++
|
|
@@ -354,8 +323,7 @@ export function main(): void {
|
|
|
354
323
|
}
|
|
355
324
|
|
|
356
325
|
console.log(
|
|
357
|
-
`\nsummary: ${totalFiles} file(s)
|
|
358
|
-
`${mode === 'dry-run' || mode === 'report' ? 'to be marked' : 'marked'}, ` +
|
|
326
|
+
`\nsummary: ${totalFiles} file(s) refused (${totalEvents} fallbacks/switch event(s) cannot be repaired by an ignorable flag), ` +
|
|
359
327
|
`${skipped} unchanged skipped, ${errors} error(s)`,
|
|
360
328
|
)
|
|
361
329
|
if (errors) process.exitCode = 1
|