dsh-workbuddy-xdpool 1.7.1 → 1.7.2

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/CHANGELOG.md CHANGED
@@ -4,275 +4,215 @@
4
4
 
5
5
  版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
6
6
 
7
- ## 1.7.1 (2026-09-28)
7
+ ## 1.7.2 — 自动化不再「一跑跑一天」
8
8
 
9
- 一句话:**你存进去的设置,终于会在重启后真的生效了。顺带让那些甩不掉的失效账号可以永久请出去。**
9
+ > 定好几点跑,就几点跑。之前是定好几点跑,然后从那一刻起每个整点都跑,跑到你睡觉。
10
10
 
11
- 这一版修的都是社区朋友报的问题([#17](https://github.com/XDTrees/dsh-workbuddy-xdpool/issues/17)、
12
- [#18](https://github.com/XDTrees/dsh-workbuddy-xdpool/issues/18)、
13
- [#19](https://github.com/XDTrees/dsh-workbuddy-xdpool/issues/19)),症状各不一样,
14
- 但核心都是同一句话:**「我明明设置过了,它当没看见。」**
11
+ **你会看到**:把签到配成 9 点,它 9 点跑了;然后 10 点又跑、11 点又跑、13 点、14 点、15 点……**每个整点准时跑一次,一直跑到第二天零点**。配了 `[9]` 的签到,一天能跑 8 次。猫猫旅行同理,满勤。
12
+
13
+ 最诡异的是**有的任务正常、有的不正常**:活跃上报配的是 10 点,它就老老实实只跑一次。同样的代码、同样的时刻、同样的配置格式,凭什么它就能安分?
14
+
15
+ **为什么**:一个字段被当成两种意思用了。
16
+
17
+ 闸门那边问的是「**配置的那个小时**花掉了没」——比如签到的 9 点,它去找的记号是 `今天的9点`。而跑完之后记账那边写的是「**现在几点**」——10 点 35 分跑完,写的就是 `今天的10点`。
18
+
19
+ **两边的答案几乎永远对不上**。于是闸门永远是开的,任务每小时都能再跑一次。至于为什么最后变成「每个整点一次」而不是每分钟一次——还有一道「同一小时内不许重复」的节流挡着,所以最密也就是每小时一发。
20
+
21
+ **那个「正常」的任务恰恰是证据**:上报配的是 10 点,而那次补跑碰巧发生在 10 点 35 分(同一小时内),它写下的记号**正好**是闸门要找的那个,于是就锁住了。纯粹是撞上的——换任何一个别的时刻,它照样失控。
22
+
23
+ 还有两个更隐蔽的变体,**即使在正确的时间点触发也会失效**:
24
+
25
+ - **一天跑两次的任务**(猫猫旅行配 `[9, 21]`):字段只有一个,存不下两个时间点。晚上 21 点跑完写下 `21`,早上那个 `9` 的记号就被顶掉了——于是它又「欠着」,22 点、23 点接着跑;
26
+ - **跑得久的任务**:记的是**跑完**那一刻。11:58 开始、12:01 结束,就记成了 `12 点`——哪怕它是准时触发的。
27
+
28
+ **怎么修的**:把「花掉的时间点」和「什么时候跑的」彻底拆开,而且花掉的时间点用**集合**记(因为一天可能有好几个)。
29
+
30
+ - **闸门**改成只认「被花掉的那个配置小时」,一次一个,最早优先;
31
+ - **节流**(同一小时不重复)改用单独的字段,不再和记账混在一起;
32
+ - **手动点「立即运行」不再吃掉当天的排期**——它是「额外再跑一次」,不是「今天就算完事了」。所以 10 点 35 分按一下按钮,不会把晚上 21 点的猫猫旅行取消掉。
33
+
34
+ ### 顺手修了一个让我很惭愧的地方:卡片一直瞒着我们
35
+
36
+ 这次的问题**本该一眼看出来**,但卡片上每个任务只显示日期:
37
+
38
+ ```
39
+ 每日签到 09:00 2026-09-28 · 2
40
+ ```
41
+
42
+ 那个 `2026-09-28` 是**日期**,不是时刻。所以一天跑 1 次还是 8 次,卡片上**是同一句话**——肉眼看不出任何区别,只能去翻日志才发现。这就是它能活这么久的原因。
43
+
44
+ 修复:卡片改显示**真实运行时刻**(用的是本来就采集、本来就传输、就是没渲染的那个 `lastRunAtMs`)。数据链路一直是通的,就差最后打印出来那一步。
45
+
46
+ ### 还没有做的(说清楚,免得以为都好了)
47
+
48
+ **运行记录仍然不落盘**,重启后当天「已跑过」的记忆会清空。所以如果你在 15:00 重启一次,已经跑过的 9/10/11 点会被认为「还没跑」,补跑一轮——**这是刻意保留的补跑设计**(笔记本睡过 10 点,醒来当天仍要跑),不是 bug。但它意味着「重启几次就补跑几次」,这点没有改变。
49
+
50
+ 真要连这个也记住,得把「花掉的时间点」和收益账本一样持久化。这次没做,因为补跑本身是对的、而每次重启补跑一轮的代价是可接受的(任务都是幂等的);把它一起改了反而会让「睡过头当天不跑」。**先说明白,不夹带。**
51
+
52
+ ### 验证
53
+
54
+ - 类型检查全绿,测试 **259 项全绿**(原 248 + 新增 11)。
55
+ - 新增 `tests/automation-schedule.test.ts` 锁住四个场景:补跑后当日不再重复、一天两次的任务各跑一次且中间安静、跨整点仍记在被消耗的那个时间点上、手动运行不吃掉当天排期。
56
+ - **反向验证**:把修复**完整撤回**成原始写法后,这 5 个关键用例全部变红(含「每整点重复执行」那条)——确认测试确实能抓住这个 bug,而不是闭着眼睛也绿。
57
+
58
+ ## 1.7.1 — 你存进去的设置,终于在重启后真的生效了
59
+
60
+ > 顺带让那些甩不掉的失效账号可以永久请出去。
61
+
62
+ 这一版修的都是社区朋友报的问题([#17](https://github.com/XDTrees/dsh-workbuddy-xdpool/issues/17)、[#18](https://github.com/XDTrees/dsh-workbuddy-xdpool/issues/18)、[#19](https://github.com/XDTrees/dsh-workbuddy-xdpool/issues/19)),症状各不一样,但核心都是同一句话:**「我明明设置过了,它当没看见。」**
15
63
 
16
64
  ### 一、重启一次,设置就白设一次
17
65
 
18
- **你会看到**:把账号使用方式调成「轮换模式」、把积分自动化打开、模型只勾了三四只……
19
- 配置明明写进文件了,重启 DSH 一看 —— 模式跳回「优先」,自动化显示「已关闭」,
20
- 模型**全部打勾**。改一次、重启一次、退一次,堪称赛博西西弗斯。
66
+ **你会看到**:把账号使用方式调成「轮换模式」、把积分自动化打开、模型只勾了三四只…… 配置明明写进文件了,重启 DSH 一看 —— 模式跳回「优先」,自动化显示「已关闭」,模型**全部打勾**。改一次、重启一次、退一次,堪称赛博西西弗斯。
21
67
 
22
- **最关键的线索**(也是报问题那位朋友观察得最准的地方):**同一张卡片上,
23
- 「今日自动化」的收益账本能正常显示**(签到 +200、猫猫旅行 +26 都在)。
68
+ **最关键的线索**(也是报问题那位朋友观察得最准的地方):**同一张卡片上,「今日自动化」的收益账本能正常显示**(签到 +200、猫猫旅行 +26 都在)。
24
69
 
25
- 账本读得出来、开关却是关的 —— 说明配置**读得到**,只是**没人去用它**。
26
- 就好比你钥匙插得进去,但没人转那一下。
70
+ 账本读得出来、开关却是关的 —— 说明配置**读得到**,只是**没人去用它**。就好比你钥匙插得进去,但没人转那一下。
27
71
 
28
- **为什么**:负责把配置写到「池子 / 模型目录 / 自动化」这三个地方的那个函数,
29
- 只在**两个事件回调**里被调用:设置变更时(0.1.5)和热更新时(0.1.7)。
72
+ **为什么**:负责把配置写到「池子 / 模型目录 / 自动化」这三个地方的那个函数,只在**两个事件回调**里被调用:设置变更时(0.1.5)和热更新时(0.1.7)。
30
73
 
31
- 问题就在这儿 —— 这俩都是**事件**,开机那一刻谁都不响。
32
- 而 0.1.7 上那个 `configure({auto})` 只管登记「这个插件有个设置页」,
33
- **既不读值也不写值**。于是三条线全停在出厂默认值上,配置文件孤零零躺在磁盘上,
34
- 写得一丝不苟,就是没人理它。
74
+ 问题就在这儿 —— 这俩都是**事件**,开机那一刻谁都不响。而 0.1.7 上那个 `configure({auto})` 只管登记「这个插件有个设置页」,**既不读值也不写值**。于是三条线全停在出厂默认值上,配置文件孤零零躺在磁盘上,写得一丝不苟,就是没人理它。
35
75
 
36
- 顺带这也解释了一个「灵异现象」:为什么**退回旧版反而正常** ——
37
- 旧版走的是另一条路(`onChange`),开机时会被触发一次。
76
+ 顺带这也解释了一个「灵异现象」:为什么**退回旧版反而正常** —— 旧版走的是另一条路(`onChange`),开机时会被触发一次。
38
77
 
39
- **怎么修的**:开机时老老实实调用一次,就一行。回归测试专门盯着「这行必须是
40
- 开机就执行的独立语句」,防止以后有人手滑把它挪回回调里。
78
+ **怎么修的**:开机时老老实实调用一次,就一行。回归测试专门盯着「这行必须是开机就执行的独立语句」,防止以后有人手滑把它挪回回调里。
41
79
 
42
80
  ### 二、自动化开关亮着「已开启」,实际一个任务都不跑
43
81
 
44
- **你会看到**:开关明明开着,界面也没报错,但**所有任务永远不会触发**。
45
- 一个安静的、什么都不做的自动化。薛定谔的积分。
82
+ **你会看到**:开关明明开着,界面也没报错,但**所有任务永远不会触发**。一个安静的、什么都不做的自动化。薛定谔的积分。
46
83
 
47
84
  **为什么**:一条自我强化的四段链条,每一环都在帮倒忙:
48
85
 
49
- 1. 配置结构里把时间表写成了「可选数组」,于是**「没设置」被自动填成了空数组 `[]`** ——
50
- 从此「我没配」和「我故意配成空」长得一模一样;
86
+ 1. 配置结构里把时间表写成了「可选数组」,于是**「没设置」被自动填成了空数组 `[]`** —— 从此「我没配」和「我故意配成空」长得一模一样;
51
87
  2. 调度器看到 `[]` 觉得「这是个有效值」,欣然采纳,**内置的默认时间表永远用不上**;
52
- 3. 判断「该不该跑」时看到空数组,直接回一句「不跑」——**永远地**;
88
+ 3. 判断「该不该跑」时看到空数组,直接回一句「不跑」—— **永远地**;
53
89
  4. 卡片在回写配置时**漏掉了「猫猫旅行」的时间**,还把空数组原样写了回去。
54
90
 
55
- 于是文件被**固化**成空的,而且**你在界面上怎么点都修不好**——
56
- 因为界面永远只会写回空数组。死循环。
91
+ 于是文件被**固化**成空的,而且**你在界面上怎么点都修不好** —— 因为界面永远只会写回空数组。死循环。
57
92
 
58
- **怎么修的**:空数组一律当作「没设置」,回落到默认时间表
59
- (签到 9 点、上报 10 点、任务 11 点、连登 12 点,猫猫旅行早晚各一趟 9 点和 21 点);
60
- 三个地方都改(构造、应用配置、卡片回写),并把时间表抽成**两端共用的一份**——
61
- 宿主和浏览器代码没法互相引用,写两份迟早会漂移,那种漂移还特别隐蔽。
62
- 顺手把「猫猫旅行永远写不回去」也修了。
93
+ **怎么修的**:空数组一律当作「没设置」,回落到默认时间表(签到 9 点、上报 10 点、任务 11 点、连登 12 点,猫猫旅行早晚各一趟 9 点和 21 点);三个地方都改(构造、应用配置、卡片回写),并把时间表抽成**两端共用的一份** —— 宿主和浏览器代码没法互相引用,写两份迟早会漂移,那种漂移还特别隐蔽。顺手把「猫猫旅行永远写不回去」也修了。
63
94
 
64
95
  ### 三、模型选择一丢,就给你「全选」
65
96
 
66
- **你会看到**:本来精挑细选勾了三四只模型,某天打开一看——**全勾上了**,
67
- 而且**真正生效的也真的变成了全部**。你的精简清单变成了自助餐。
97
+ **你会看到**:本来精挑细选勾了三四只模型,某天打开一看 —— **全勾上了**,而且**真正生效的也真的变成了全部**。你的精简清单变成了自助餐。
68
98
 
69
- **为什么**:「没有选择」和「选择丢了」这两种情况,在代码里长得**一模一样**
70
- (都是 `undefined`)。而它的兜底是个**空对象**,空对象翻译过来恰好是
71
- **「全部启用」**。
99
+ **为什么**:「没有选择」和「选择丢了」这两种情况,在代码里长得**一模一样**(都是 `undefined`)。而它的兜底是个**空对象**,空对象翻译过来恰好是**「全部启用」**。
72
100
 
73
- 所以一旦选择丢失,系统不是「保持不变」,而是**朝最离谱的方向**——
74
- 把你有意关掉的模型全放出来。
101
+ 所以一旦选择丢失,系统不是「保持不变」,而是**朝最离谱的方向** —— 把你有意关掉的模型全放出来。
75
102
 
76
- **怎么修的**:兜底改成「**沿用现在正在生效的那份选择**」。
77
- 丢了就丢了,至少不会变成「全开」。丢失只会原地不动,不会放飞自我。
103
+ **怎么修的**:兜底改成「**沿用现在正在生效的那份选择**」。丢了就丢了,至少不会变成「全开」。丢失只会原地不动,不会放飞自我。
78
104
 
79
105
  ### 四、失效账号终于可以彻底请出去
80
106
 
81
- **这个不是 bug,是缺功能**。池子里混着国内版和国际版账号,国际版更容易触发风控。
82
- 一旦某个号废了,它就一直赖在池子里 —— 而且**没有任何办法把它彻底弄走**。
107
+ **这个不是 bug,是缺功能**。池子里混着国内版和国际版账号,国际版更容易触发风控。一旦某个号废了,它就一直赖在池子里 —— 而且**没有任何办法把它彻底弄走**。
83
108
 
84
- 现有三条路都不行:「停用」只是让它不参与轮换(**账号还在卡片上、凭据文件还会被读取**);
85
- 命令行 `remove` 只对你手动导入的快照有效;`disabledAccountIds` 同样是「排除轮换」,
86
- 不是「移除」。
109
+ 现有三条路都不行:「停用」只是让它不参与轮换(**账号还在卡片上、凭据文件还会被读取**);命令行 `remove` 只对你手动导入的快照有效;`disabledAccountIds` 同样是「排除轮换」,不是「移除」。
87
110
 
88
111
  最要命的是:**只要你在桌面 App 里再登一次这个号,它就又回来了**。打地鼠。
89
112
 
90
113
  **怎么修的**:新增**「移出池子」**,和「暂时停用」明确分开:
91
114
 
92
- - **卡片**上每个账号多了个「移出池子」按钮,下面还有一块「**已移出的账号**」——
93
- 每条都能**恢复**。移除不是单向门,点错了能找回来;
94
- - **命令行**同步支持 `ignore` / `unignore` / `ignored`,`accounts` 也会列出已移出的号
95
- (不然账号凭空消失,你还以为插件坏了);
115
+ - **卡片**上每个账号多了个「移出池子」按钮,下面还有一块「**已移出的账号**」—— 每条都能**恢复**。移除不是单向门,点错了能找回来;
116
+ - **命令行**同步支持 `ignore` / `unignore` / `ignored`,`accounts` 也会列出已移出的号(不然账号凭空消失,你还以为插件坏了);
96
117
  - 被移出的账号**连凭据都不再读取**,所以它**不会在你重新登录后又偷偷溜回来**。
97
118
 
98
- 关于「为什么不干脆把凭据文件删掉」:那个文件就是桌面 App 当前登录的凭证,
99
- 删了会把 App 一起登出;而且下次登录它还会生成回来。属于既伤人、又没用。
119
+ 关于「为什么不干脆把凭据文件删掉」:那个文件就是桌面 App 当前登录的凭证,删了会把 App 一起登出;而且下次登录它还会生成回来。属于既伤人、又没用。
100
120
 
101
- 移出名单存在插件自己的文件里(`~/.dsh/.workbuddy-xdpool/ignored.json`),
102
- 这样**命令行和卡片读写的是同一份**——不然会出现「卡片能改、终端改不了」的尴尬。
121
+ 移出名单存在插件自己的文件里(`~/.dsh/.workbuddy-xdpool/ignored.json`),这样**命令行和卡片读写的是同一份** —— 不然会出现「卡片能改、终端改不了」的尴尬。
103
122
 
104
123
  ### 五、国际版模型的「倍率」和「免费」标记回来了
105
124
 
106
- **你会看到**:国际版那几个模型的倍率(`x0.79` 这种)和「免费」标签不见了。
107
- 光秃秃一行模型名,看着像没人管的弃儿。
125
+ **你会看到**:国际版那几个模型的倍率(`x0.79` 这种)和「免费」标签不见了。光秃秃一行模型名,看着像没人管的弃儿。
108
126
 
109
127
  **为什么**(这个是拉了真实接口逐字段对出来的,两个毛病叠加):
110
128
 
111
- 1. 上游**明明发了** `tags` 字段,但我们的解析代码**从头到尾就没读它**——
112
- 读的时候没读,转换的时候又丢一次。所以卡片的标签逻辑**对两个平台的所有模型
113
- 都不可能命中**,白写了;
114
- 2. 「免费」一直在等一个叫 `free` 的标签出现,而**两个平台从来没发过这种标签**
115
- (实测标签是 `craft`、`text-to-image` 这类东西)。
116
- 真正的「免费」写法是 **`credits: "x0.00"`**——国际版有三只模型就是零费率,
117
- 它们一直免费,我们一直不知道。
129
+ 1. 上游**明明发了** `tags` 字段,但我们的解析代码**从头到尾就没读它** —— 读的时候没读,转换的时候又丢一次。所以卡片的标签逻辑**对两个平台的所有模型都不可能命中**,白写了;
130
+ 2. 「免费」一直在等一个叫 `free` 的标签出现,而**两个平台从来没发过这种标签**(实测标签是 `craft`、`text-to-image` 这类东西)。真正的「免费」写法是 **`credits: "x0.00"`** —— 国际版有三只模型就是零费率,它们一直免费,我们一直不知道。
118
131
 
119
- **怎么修的**:把 `tags` 老老实实读进来传下去;「免费」改成**看倍率是不是 0**
120
- (谁也不会为了 0 费率专门发个标签);倍率是 0 的时候**不再显示「x0.00 积分」**——
121
- 零费率的免费模型旁边挂个 0,还不如干脆说"免费"。
132
+ **怎么修的**:把 `tags` 老老实实读进来传下去;「免费」改成**看倍率是不是 0**(谁也不会为了 0 费率专门发个标签);倍率是 0 的时候**不再显示「x0.00 积分」** —— 零费率的免费模型旁边挂个 0,还不如干脆说「免费」。
122
133
 
123
- 另外给「静态兜底列表」也补上了倍率:以前上游一抽风就回退到那份没倍率的列表,
124
- 表现就是「倍率突然全没了」,你还以为是插件坏了,其实只是网络打了个嗝。
134
+ 另外给「静态兜底列表」也补上了倍率:以前上游一抽风就回退到那份没倍率的列表,表现就是「倍率突然全没了」,你还以为是插件坏了,其实只是网络打了个嗝。
125
135
 
126
136
  ### 六、顺便修了一个让我很尴尬的事
127
137
 
128
- 上一版我说「可以了」,结果有人(其实就是我)改完代码**只改了源码仓库,
129
- 忘了同步到 DSH 真正加载的那个目录**。所以怎么重启都看不到新按钮——
130
- 代码在隔壁房间好好地跑着,你在这边找。
138
+ 上一版我说「可以了」,结果有人(其实就是我)改完代码**只改了源码仓库,忘了同步到 DSH 真正加载的那个目录**。所以怎么重启都看不到新按钮 —— 代码在隔壁房间好好地跑着,你在这边找。
131
139
 
132
140
  这版已经同步部署,并留了备份可以回退。
133
141
 
134
142
  ### 验证
135
143
 
136
144
  - 类型检查全绿,测试 **248 项全绿**(原 222 + 新增 26)。
137
- - 新增的两个测试文件**逐项做了反向验证**:把修复**撤回**去,测试必须变红
138
- (撤回一处 → 1 项红;撤回另一处 → 2 项红;撤回忽略过滤 → 1 项红;
139
- 撤回标签读取 → 3 项红)。确认这些测试不是"闭着眼睛也全绿"的装饰品。
140
- - 拿**真实构建产物**跑端到端:空时间表能解析出默认值、被移出的账号在
141
- **凭据文件还在的情况下不会被重新扫描进来**、恢复后能回来。
142
- - 拿**真实的国际版接口数据**跑完整解析链,确认倍率和免费标记的实际渲染结果
143
- (20 个模型有倍率、3 个正确识别为免费)。
145
+ - 新增的两个测试文件**逐项做了反向验证**:把修复**撤回**去,测试必须变红(撤回一处 → 1 项红;撤回另一处 → 2 项红;撤回忽略过滤 → 1 项红;撤回标签读取 → 3 项红)。确认这些测试不是「闭着眼睛也全绿」的装饰品。
146
+ - 拿**真实构建产物**跑端到端:空时间表能解析出默认值、被移出的账号在**凭据文件还在的情况下不会被重新扫描进来**、恢复后能回来。
147
+ - 拿**真实的国际版接口数据**跑完整解析链,确认倍率和免费标记的实际渲染结果(20 个模型有倍率、3 个正确识别为免费)。
144
148
 
145
149
  ### 最后
146
150
 
147
- 感谢 [@hmtime](https://github.com/hmtime) 提交 #17 / #18。
148
- 报告里「配置读得到、但没被应用」和「账本能显示、开关却是关的」这两句
149
- 几乎是直接指向了根因 —— 光看现象的话,很容易一头扎进"肯定是写入失败了"的坑里
150
- 查半天。这样的报告质量,值得一版单独的更新日志来谢。
151
+ 感谢 [@hmtime](https://github.com/hmtime) 提交 #17 / #18。报告里「配置读得到、但没被应用」和「账本能显示、开关却是关的」这两句几乎是直接指向了根因 —— 光看现象的话,很容易一头扎进「肯定是写入失败了」的坑里查半天。这样的报告质量,值得一版单独的更新日志来谢。
151
152
 
152
- ## 1.7.0 (2026-09-25)
153
+ ## 1.7.0 — 同一构建通吃 DSH 0.1.5 / 0.1.7,设置入口搬进左栏
153
154
 
154
155
  一句话:**这一版终于能在 DSH 0.1.7 上活下来了,顺带把设置入口搬进了左栏、把页面重新排了一遍。**
155
156
 
156
- 以前一个构建只能伺候 0.1.5 那条线,0.1.7 上是真的会出事 —— 不是"卡片不显示"这种小事,
157
- 是整个 DSH 直接退出。现在两条线一个构建通吃。
157
+ 以前一个构建只能伺候 0.1.5 那条线,0.1.7 上是真的会出事 —— 不是"卡片不显示"这种小事,是整个 DSH 直接退出。现在两条线一个构建通吃。
158
158
 
159
159
  ### 一、0.1.7 上一启用插件,DSH 就原地去世
160
160
 
161
161
  先说症状,因为它实在太有迷惑性了:用户装好插件、点启用、**进程直接没了**。
162
162
 
163
- 桌面端只留下一句 `DSH Host exited (1)`。日志?日志里最后一行还是个无关紧要的
164
- warning,然后就**戛然而止**,一个 error 都没有。
163
+ 桌面端只留下一句 `DSH Host exited (1)`。日志?日志里最后一行还是个无关紧要的 warning,然后就**戛然而止**,一个 error 都没有。
165
164
 
166
- 这种"日志被齐刷刷截断"的样子,其实是宿主的一个设计在作祟:DSH 装了 `installFailLoud`,
167
- 进程里**任何**没被捕获的异常、任何没接住的 Promise rejection,都会让它当场 `exit(1)`。
168
- 而那句真正的死因只写进 stderr —— 打包版 Windows 应用压根没有控制台,于是它就
169
- 悄无声息地消失了。查了半天日志找不到错,别怀疑自己,它真的没写进去。
165
+ 这种"日志被齐刷刷截断"的样子,其实是宿主的一个设计在作祟:DSH 装了 `installFailLoud`,进程里**任何**没被捕获的异常、任何没接住的 Promise rejection,都会让它当场 `exit(1)`。而那句真正的死因只写进 stderr —— 打包版 Windows 应用压根没有控制台,于是它就悄无声息地消失了。查了半天日志找不到错,别怀疑自己,它真的没写进去。
170
166
 
171
167
  那么是谁抛的?**三个坑叠在一起**,而且全在插件这边,内核无辜:
172
168
 
173
- **坑 1:宿主那边的 API 换了个名字,老代码还在敲旧门。**
174
- 0.1.7 把 `settings.installSection(...)` 整个删了,换成了 `configure({auto}, owner)`。
175
- 我们的代码虽然做了"先看看有没有"的判断,但问题是 —— 判断失败时它只是 **warn 一声就算了**,
176
- 设置页从此永远不出现;而万一服务真在场,它就**直接裸调**,在 0.1.7 上"啪"地从
177
- `apply()` 里抛出去。宿主一看:未捕获异常,走你。
169
+ **坑 1:宿主那边的 API 换了个名字,老代码还在敲旧门。** 0.1.7 把 `settings.installSection(...)` 整个删了,换成了 `configure({auto}, owner)`。我们的代码虽然做了"先看看有没有"的判断,但问题是 —— 判断失败时它只是 **warn 一声就算了**,设置页从此永远不出现;而万一服务真在场,它就**直接裸调**,在 0.1.7 上"啪"地从 `apply()` 里抛出去。宿主一看:未捕获异常,走你。
178
170
 
179
- **坑 2:客户端 `inject` 里写了两个"有你没我"的服务名。**
180
- 0.1.5 提供 `settingsScope`,0.1.7 换成了 `configForms`。而 cordis 的依赖闸门是**硬**的:
181
- `inject` 里只要有一个服务不存在,`apply` 就**永远不会被执行**,fiber 就永远挂在那里
182
- PENDING。桌面端报 `renderer boot failed (plugins: dsh-workbuddy-xdpool)`,
183
- 然后拒绝打开。它不报错,它就是不开。
171
+ **坑 2:客户端 `inject` 里写了两个"有你没我"的服务名。** 0.1.5 提供 `settingsScope`,0.1.7 换成了 `configForms`。而 cordis 的依赖闸门是**硬**的:`inject` 里只要有一个服务不存在,`apply` 就**永远不会被执行**,fiber 就永远挂在那里 PENDING。桌面端报 `renderer boot failed (plugins: dsh-workbuddy-xdpool)`,然后拒绝打开。它不报错,它就是不开。
184
172
 
185
- **坑 3:卡片注册的那个槽位,0.1.7 里根本不存在。**
186
- `settings.plugin.item` 是 0.1.5 的专属座位。0.1.7 的设置左栏只认 `settings.section`
187
- (内置的通用 / 模型 / 插件页都坐那儿)。我们一直往一个不存在的座位上放东西,
188
- 于是卡片安安静静地不显示 —— 不报错、不警告,就是不出现。
173
+ **坑 3:卡片注册的那个槽位,0.1.7 里根本不存在。** `settings.plugin.item` 是 0.1.5 的专属座位。0.1.7 的设置左栏只认 `settings.section`(内置的通用 / 模型 / 插件页都坐那儿)。我们一直往一个不存在的座位上放东西,于是卡片安安静静地不显示 —— 不报错、不警告,就是不出现。
189
174
 
190
175
  **怎么修的:** 一律改成"先探路、再走",不猜。
191
176
 
192
- - 宿主侧:有 `installSection` 就用它(0.1.5),没有就用 `configure({auto:true})`(0.1.7),
193
- 两个都没有才 warn。写入时的命名空间也得跟着线路换 —— 0.1.5 用自己起的名字,
194
- 0.1.7 得用**宿主给这个插件分配的条目 id**(`ctx.fiber.entry.options.id`)。
195
- 这里猜错的代价是每次保存都报 `No configurable plugin entry`。
196
- - 客户端:`inject` 只留两条线**都**有的 `slots` / `locale`,设置服务改用 `ctx.get()`
197
- 软探测(服务不在就返回 undefined,不抛)。
198
- - 顺手声明 `engines.dsh: ">=0.1.5-rc.1 <0.2.0"`,让插件市场把两条线都认成兼容;
199
- 另外把 `dsh.client.inject` 里早就停更的 `dsh-client-runtime` 噪声清掉了。
177
+ - 宿主侧:有 `installSection` 就用它(0.1.5),没有就用 `configure({auto:true})`(0.1.7),两个都没有才 warn。写入时的命名空间也得跟着线路换 —— 0.1.5 用自己起的名字,0.1.7 得用**宿主给这个插件分配的条目 id**(`ctx.fiber.entry.options.id`)。这里猜错的代价是每次保存都报 `No configurable plugin entry`。
178
+ - 客户端:`inject` 只留两条线**都**有的 `slots` / `locale`,设置服务改用 `ctx.get()` 软探测(服务不在就返回 undefined,不抛)。
179
+ - 顺手声明 `engines.dsh: ">=0.1.5-rc.1 <0.2.0"`,让插件市场把两条线都认成兼容;另外把 `dsh.client.inject` 里早就停更的 `dsh-client-runtime` 噪声清掉了。
200
180
 
201
181
  ### 二、0.1.7 上保存没反应,或者说"没保存成功"
202
182
 
203
- 0.1.7 有个硬门禁:**schema 里一个 `volatile` 字段都没标的条目,直接拒收**。
204
- `write()` 抛 `has no volatile fields`,`describe()` 干脆跳过它 —— 页面就是一片空白。
205
- 我们的 Config 当时一个都没标,于是保存必炸、页面必空。
183
+ 0.1.7 有个硬门禁:**schema 里一个 `volatile` 字段都没标的条目,直接拒收**。`write()` 抛 `has no volatile fields`,`describe()` 干脆跳过它 —— 页面就是一片空白。我们的 Config 当时一个都没标,于是保存必炸、页面必空。
206
184
 
207
- 还有个更阴的:0.1.7 把 volatile 字段以**活引用** `{get(): T}` 的形式交回来
208
- (这样改设置不用重挂载就能生效)。我们直接拿它去比较 —— 拿一个对象比一个字符串,
209
- 结果就是**刚存进去的值,读回来是"没设置"**。
185
+ 还有个更阴的:0.1.7 把 volatile 字段以**活引用** `{get(): T}` 的形式交回来(这样改设置不用重挂载就能生效)。我们直接拿它去比较 —— 拿一个对象比一个字符串,结果就是**刚存进去的值,读回来是"没设置"**。
210
186
 
211
187
  对,这就是那个"我明明填了保留积分,重开又变回 0"的元凶。不是没存进去,是读错了。
212
188
 
213
- **怎么修的:** Config 全部 12 个字段都过一遍 `asVolatile()` —— schemastery 3.18.3+
214
- 会调 `.volatile()`;0.1.5 那条线是 3.18.2,压根没这个方法,那就退化成什么都不做
215
- (不能手写 `meta.volatile`,那会绕过 schemastery 自己的摆放校验,塞给 0.1.5 一个
216
- 它看不懂的东西)。读取则统一走 `unwrapVolatileDeep()` 把活引用剥开。
217
- 另外监听 `loader/volatile-update`,保存后运行中的实例立刻重读,不用重启。
189
+ **怎么修的:** Config 全部 12 个字段都过一遍 `asVolatile()` —— schemastery 3.18.3+ 会调 `.volatile()`;0.1.5 那条线是 3.18.2,压根没这个方法,那就退化成什么都不做(不能手写 `meta.volatile`,那会绕过 schemastery 自己的摆放校验,塞给 0.1.5 一个它看不懂的东西)。读取则统一走 `unwrapVolatileDeep()` 把活引用剥开。另外监听 `loader/volatile-update`,保存后运行中的实例立刻重读,不用重启。
218
190
 
219
191
  ### 三、设置入口搬进左栏 + 页面重新排了一遍
220
192
 
221
193
  - **入口**:改注册到 `settings.section`,左栏现在多出一行,名字叫 **XD Pool**。
222
- - **图标**:这个槽位的注册契约只投影 `id` / `order` / `label`,**没有 icon 字段** ——
223
- 宿主对不认识的 id 一律画个齿轮。想换图标只能自己动手:照着 `dshmarket` 的做法,
224
- 按 label 在 DOM 里认出自己那一行、打个标记,再用 CSS 把宿主的 svg 藏起来,
225
- 用 `mask-image` + `currentColor` 把自己的图标盖上去(颜色跟着主题走)。
226
- 外加一个 `MutationObserver` 盯着 —— 切语言、切主题时行元素会被重建,标记会掉。
227
- - **页面**:以前要点一下才展开,现在**整页直接铺开**,并且重新排成分区卡片:
228
- 页头(图标 / 标题 / 描述,右边放「重新检测账号」「清除冷却」)、
229
- 状态卡片(地区切换 + 状态灯 + 汇总 + 账号使用方式)、
230
- 自动化卡片、账号卡片、模型卡片。
231
- 顺带修了个陈年小毛病:原来有个 `if (!open) return`,意思是**没展开就一次都不去
232
- 拉状态** —— 所以你刚点开永远是空的,得傻等一轮轮询。现在挂载即拉。
194
+ - **图标**:这个槽位的注册契约只投影 `id` / `order` / `label`,**没有 icon 字段** —— 宿主对不认识的 id 一律画个齿轮。想换图标只能自己动手:照着 `dshmarket` 的做法,按 label 在 DOM 里认出自己那一行、打个标记,再用 CSS 把宿主的 svg 藏起来,用 `mask-image` + `currentColor` 把自己的图标盖上去(颜色跟着主题走)。外加一个 `MutationObserver` 盯着 —— 切语言、切主题时行元素会被重建,标记会掉。
195
+ - **页面**:以前要点一下才展开,现在**整页直接铺开**,并且重新排成分区卡片:页头(图标 / 标题 / 描述,右边放「重新检测账号」「清除冷却」)、状态卡片(地区切换 + 状态灯 + 汇总 + 账号使用方式)、自动化卡片、账号卡片、模型卡片。顺带修了个陈年小毛病:原来有个 `if (!open) return`,意思是**没展开就一次都不去拉状态** —— 所以你刚点开永远是空的,得傻等一轮轮询。现在挂载即拉。
233
196
 
234
197
  ### 四、顺手修的几件小事(都来自 0.1.7 真机排查)
235
198
 
236
- - **取密钥的超时 10 秒 → 30 秒。** 那个子进程是拿 WorkBuddy 的 Electron 二进制当
237
- 普通 Node 跑,冷启动第一次 spawn 实测就要 4.5 秒。碰上同时在跑 pnpm install、
238
- 或者杀毒软件在全盘扫描,10 秒预算直接被击穿 → 所有加密凭据被读成"未登录",
239
- 还要再背 60 秒负缓存。成功结果本来就是进程级缓存,加长预算最多只付一次。
240
- - **补上盘符根目录。** `D:\workbuddy\`、`D:\workbuddyai\` 是真实安装位置,
241
- 可我们只扫 `Program Files`,一级扫描永远够不着它们。
242
- - **顶层 IIFE 补 `.catch()`。** 那个 IIFE 里的 `await` 一旦失败就变成 unhandled
243
- rejection,在 `installFailLoud` 下同样是直接杀宿主。
244
-
245
- - **保存收益账本失败不再拖垮宿主。** 这条来自社区反馈:以前 scheduler 把「写
246
- settings」的调用放在 `try` 里但**没 await** —— 而 `try/catch` 只抓得住同步抛错,
247
- Promise 的 rejection 会直接漏成 unhandled rejection,在 `installFailLoud` 下
248
- 就是 `exit(1)`。那位朋友遇到的正是这个:打开设置页 → 读状态 → 滚动收益账本 →
249
- 写盘失败 → 整个 DSH 起不来。他把原因归到「写入方法名从 `set()` 改成了
250
- `update()`」,那确实是当时那一次抛错的直接原因(我们 1.6.0 已经改过了),但
251
- **结构上的隐患当时还在**:只要保存以任何理由失败(服务缺失、文件被锁、校验
252
- 不通过),宿主照样会崩。现在给持久化回调的返回值挂上 rejection 处理,同时保留
253
- 同步抛错的原有兜底 —— 保存失败只会记一条 warning,账本继续在内存里服务卡片,
254
- 绝不影响已经领到的奖励。
199
+ - **取密钥的超时 10 秒 → 30 秒。** 那个子进程是拿 WorkBuddy 的 Electron 二进制当普通 Node 跑,冷启动第一次 spawn 实测就要 4.5 秒。碰上同时在跑 pnpm install、或者杀毒软件在全盘扫描,10 秒预算直接被击穿 → 所有加密凭据被读成"未登录",还要再背 60 秒负缓存。成功结果本来就是进程级缓存,加长预算最多只付一次。
200
+ - **补上盘符根目录。** `D:\workbuddy\`、`D:\workbuddyai\` 是真实安装位置,可我们只扫 `Program Files`,一级扫描永远够不着它们。
201
+ - **顶层 IIFE 补 `.catch()`。** 那个 IIFE 里的 `await` 一旦失败就变成 unhandled rejection,在 `installFailLoud` 下同样是直接杀宿主。
202
+ - **保存收益账本失败不再拖垮宿主。** 这条来自社区反馈:以前 scheduler 把「写 settings」的调用放在 `try` 里但**没 await** —— 而 `try/catch` 只抓得住同步抛错,Promise 的 rejection 会直接漏成 unhandled rejection,在 `installFailLoud` 下就是 `exit(1)`。那位朋友遇到的正是这个:打开设置页 → 读状态 → 滚动收益账本 → 写盘失败 → 整个 DSH 起不来。他把原因归到「写入方法名从 `set()` 改成了 `update()`」,那确实是当时那一次抛错的直接原因(我们 1.6.0 已经改过了),但**结构上的隐患当时还在**:只要保存以任何理由失败(服务缺失、文件被锁、校验不通过),宿主照样会崩。现在给持久化回调的返回值挂上 rejection 处理,同时保留同步抛错的原有兜底 —— 保存失败只会记一条 warning,账本继续在内存里服务卡片,绝不影响已经领到的奖励。
255
203
 
256
204
  ### 验证
257
205
 
258
206
  - `typecheck` / `typecheck:client` 全绿;vitest **222 用例全绿**(原 198 + 新增 24)。
259
- - 新增 `tests/host-compat.test.ts`:两条线的注册分支互斥且都不抛、两个 API 都没有时
260
- 只 warn、全部字段都标了 volatile、活引用的浅 / 深解引用、`inject` 不含互斥服务名、
261
- 注册槽位是 `settings.section`、0.1.7 用条目 id / 0.1.5 用 namespace、
262
- 整页无折叠且轮询不再依赖展开状态、左栏名称与图标注入、异步持久化失败不产生
263
- unhandled rejection(该用例经反向验证:还原成旧写法时它会失败)。
264
- - 拿**真实的 0.1.7 schemastery 3.18.4** 解析**真实的 Config**(12 个字段全部变成
265
- 活引用、schema 校验无异常);再用假宿主加载真实产物 `lib/index.js` / `lib/client.js`,
266
- 确认 `apply()` 在 0.1.7 / 0.1.5 / 两个服务都没有 这三种情形下都不抛异常。
207
+ - 新增 `tests/host-compat.test.ts`:两条线的注册分支互斥且都不抛、两个 API 都没有时只 warn、全部字段都标了 volatile、活引用的浅 / 深解引用、`inject` 不含互斥服务名、注册槽位是 `settings.section`、0.1.7 用条目 id / 0.1.5 用 namespace、整页无折叠且轮询不再依赖展开状态、左栏名称与图标注入、异步持久化失败不产生 unhandled rejection(该用例经反向验证:还原成旧写法时它会失败)。
208
+ - 拿**真实的 0.1.7 schemastery 3.18.4** 解析**真实的 Config**(12 个字段全部变成活引用、schema 校验无异常);再用假宿主加载真实产物 `lib/index.js` / `lib/client.js`,确认 `apply()` 在 0.1.7 / 0.1.5 / 两个服务都没有 这三种情形下都不抛异常。
267
209
  - `lib/` 与 `src/` 同步重建(不然从 GitHub 源码装的人拿到的还是旧产物)。
268
210
 
269
211
  ### 最后
270
212
 
271
- 这一版由用户在本机 DSH Desktop 2.0.14(内核 0.1.7-rc.1)实测确认:
272
- **入口出来了、卡片显示了、名字和图标都对了。**
213
+ 这一版由用户在本机 DSH Desktop 2.0.14(内核 0.1.7-rc.1)实测确认:**入口出来了、卡片显示了、名字和图标都对了。**
273
214
 
274
- 感谢反馈问题的朋友 —— 那个"日志戛然而止"的现象描述得非常关键,否则光看日志
275
- 根本无从下手。
215
+ 感谢反馈问题的朋友 —— 那个"日志戛然而止"的现象描述得非常关键,否则光看日志根本无从下手。
276
216
 
277
217
  ## 1.6.1 (2026-09-24)
278
218
 
package/lib/bin.js CHANGED
@@ -3798,7 +3798,7 @@ var WorkBuddyScheduler = class {
3798
3798
  async runNow(kind, force = false) {
3799
3799
  const today = dayKey(this.now());
3800
3800
  const state = this.states[kind];
3801
- if (!force && state.lastRunSlot === slotKey(this.now())) return { ...state };
3801
+ if (!force && state.lastFiredHour === slotKey(this.now())) return { ...state };
3802
3802
  this.busy = true;
3803
3803
  try {
3804
3804
  await this.runJob(kind, today);
@@ -3896,13 +3896,46 @@ var WorkBuddyScheduler = class {
3896
3896
  * two hours still runs twice a day — but a job whose hour passed while DSH was
3897
3897
  * closed runs immediately on the next tick instead of waiting for tomorrow.
3898
3898
  */
3899
- isDue(kind, now) {
3899
+ /**
3900
+ * The configured slot `kind` should consume at `now`, or undefined when none.
3901
+ *
3902
+ * Returns the SLOT STRING (not a boolean) because the caller must record the
3903
+ * same value it acted on. Returning a boolean was the original bug's enabler:
3904
+ * the tick asked "is it due", then `runJob` independently wrote "what time is
3905
+ * it now" — and those two answers were almost never equal.
3906
+ *
3907
+ * CATCH-UP semantics are deliberate: a candidate fires once its hour has
3908
+ * PASSED and its slot is still unconsumed, so a laptop that slept through
3909
+ * 10:00 still runs the job when it wakes, on the same day.
3910
+ *
3911
+ * The EARLIEST unconsumed candidate wins, which is what keeps a two-hour job
3912
+ * (`travelHours: [9, 21]`) whole: consuming the earliest due slot leaves the
3913
+ * later one for its own hour.
3914
+ */
3915
+ dueSlot(kind, now) {
3900
3916
  const hours = this.hoursOf(kind);
3901
- if (hours.length === 0) return false;
3902
- const { hour } = zonedParts(now);
3903
- const current = Number(hour);
3917
+ if (hours.length === 0) return void 0;
3918
+ const current = Number(zonedParts(now).hour);
3904
3919
  const today = dayKey(now);
3905
- return hours.some((candidate) => candidate <= current && this.states[kind].lastRunSlot !== `${today}T${String(candidate).padStart(2, "0")}`);
3920
+ const fired = this.firedSlotsOf(kind);
3921
+ for (const candidate of [...hours].sort((a, b) => a - b)) {
3922
+ if (candidate > current) break;
3923
+ const slot = `${today}T${String(candidate).padStart(2, "0")}`;
3924
+ if (!fired.has(slot)) return slot;
3925
+ }
3926
+ }
3927
+ /** Today's consumed slots for one job, as a set. */
3928
+ firedSlotsOf(kind) {
3929
+ const stored = this.states[kind].firedSlots;
3930
+ if (stored === void 0) return /* @__PURE__ */ new Set();
3931
+ const today = dayKey(this.now());
3932
+ return new Set(stored.filter((slot) => slot.startsWith(`${today}T`)));
3933
+ }
3934
+ /** Record one consumed slot on a job's state. */
3935
+ consumeSlot(kind, slot) {
3936
+ const state = this.states[kind];
3937
+ const today = dayKey(this.now());
3938
+ state.firedSlots = [.../* @__PURE__ */ new Set([...this.firedSlotsOf(kind), slot])].filter((entry) => entry.startsWith(`${today}T`)).sort();
3906
3939
  }
3907
3940
  async tick() {
3908
3941
  if (!this.enabled || this.stopped || this.busy) return;
@@ -3913,9 +3946,10 @@ var WorkBuddyScheduler = class {
3913
3946
  const today = dayKey(now);
3914
3947
  for (const kind of JOB_KINDS) {
3915
3948
  if (this.stopped) return;
3916
- if (this.states[kind].lastRunSlot === slot) continue;
3917
- if (!this.isDue(kind, now)) continue;
3918
- await this.runJob(kind, today);
3949
+ if (this.states[kind].lastFiredHour === slot) continue;
3950
+ const consumed = this.dueSlot(kind, now);
3951
+ if (consumed === void 0) continue;
3952
+ await this.runJob(kind, today, consumed);
3919
3953
  }
3920
3954
  } catch (error) {
3921
3955
  this.logger.warn?.("dsh-workbuddy-xdpool automation tick failed:", error);
@@ -4001,6 +4035,12 @@ var WorkBuddyScheduler = class {
4001
4035
  /**
4002
4036
  * Run one job against every eligible account and record the outcome.
4003
4037
  *
4038
+ * `consumedSlot` is the configured slot this run spends (`dueSlot`'s answer).
4039
+ * It is passed IN rather than recomputed here so the value recorded is exactly
4040
+ * the value the schedule decided on — the defect this replaces wrote
4041
+ * `slotKey(now)` instead, i.e. the clock time the run finished at, which
4042
+ * almost never equals the configured candidate the gate had compared against.
4043
+ *
4004
4044
  * The task job runs in TWO passes. The first sends the event chains that light
4005
4045
  * up client-scored tasks; the second collects rewards. They are separate
4006
4046
  * because scoring lands asynchronously — a chain sent and claimed within the
@@ -4008,7 +4048,7 @@ var WorkBuddyScheduler = class {
4008
4048
  * while claiming wants the whole pool to have been lit up first. Splitting
4009
4049
  * them costs one shared wait instead of one wait per account.
4010
4050
  */
4011
- async runJob(kind, today) {
4051
+ async runJob(kind, today, consumedSlot) {
4012
4052
  const accountWord = kind === "report" ? "report" : kind;
4013
4053
  let ok = 0;
4014
4054
  let failed = 0;
@@ -4073,7 +4113,8 @@ var WorkBuddyScheduler = class {
4073
4113
  if (index < accounts.length - 1 && this.delayMs > 0 && !this.stopped) await sleep(this.delayMs);
4074
4114
  }
4075
4115
  const state = this.states[kind];
4076
- state.lastRunSlot = slotKey(this.now());
4116
+ if (consumedSlot !== void 0) this.consumeSlot(kind, consumedSlot);
4117
+ state.lastFiredHour = slotKey(this.now());
4077
4118
  state.lastRunDate = today;
4078
4119
  state.lastRunAtMs = this.now().getTime();
4079
4120
  state.ok = ok;
package/lib/client.js CHANGED
@@ -1270,7 +1270,7 @@ window.__ModuleLoader__.load({
1270
1270
  }),
1271
1271
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1272
1272
  className: "dsm-workbuddy-xdpool-auto-job-last",
1273
- children: job?.lastRunDate === void 0 ? t?.("row.autoNever") ?? "not run yet" : `${job.lastRunDate} · ${job.ok}${job.failed > 0 ? `/${job.failed}` : ""}`
1273
+ children: job?.lastRunAtMs === void 0 ? t?.("row.autoNever") ?? "not run yet" : `${formatTime(job.lastRunAtMs)} · ${job.ok}${job.failed > 0 ? `/${job.failed}` : ""}`
1274
1274
  }),
1275
1275
  job?.progress === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1276
1276
  className: "dsm-workbuddy-xdpool-auto-job-note",
package/lib/index.d.ts CHANGED
@@ -1265,6 +1265,34 @@ interface AutomationJobState {
1265
1265
  * repeat tick inside the same hour is still refused.
1266
1266
  */
1267
1267
  lastRunSlot?: string;
1268
+ /**
1269
+ * Every configured slot consumed TODAY, as `YYYY-MM-DDTHH`.
1270
+ *
1271
+ * This is what actually gates a re-run, and it is a SET rather than a single
1272
+ * slot because one field could not express the contract: a job configured for
1273
+ * `[9, 21]` runs twice, and a single "last slot" value can only remember one
1274
+ * of them — the second run erased the first, so the 9 o'clock candidate looked
1275
+ * unconsumed again and the job re-fired every hour after 21:00.
1276
+ *
1277
+ * The values are the CONFIGURED hours that were spent, never the clock time
1278
+ * the run happened to finish at. Storing the finish time was the original
1279
+ * defect: the reader compared it against a configured candidate, so the two
1280
+ * almost never matched and the gate stayed open all day.
1281
+ *
1282
+ * In-memory like the rest of `states`: this is a per-process record, and the
1283
+ * catch-up design deliberately re-runs an hour that passed while DSH was
1284
+ * closed. See `automationEarnings` for the ledger that DOES persist.
1285
+ */
1286
+ firedSlots?: readonly string[];
1287
+ /**
1288
+ * The clock slot the last run STARTED in (`YYYY-MM-DDTHH`).
1289
+ *
1290
+ * Separate from {@link firedSlots} on purpose: this is a throttle ("do not
1291
+ * start twice inside the same hour"), while `firedSlots` is the schedule
1292
+ * ledger. Conflating the two is what let a catch-up run at 13:00 erase the
1293
+ * record of the 9 o'clock slot.
1294
+ */
1295
+ lastFiredHour?: string;
1268
1296
  /** Epoch ms of the last completed run. */
1269
1297
  lastRunAtMs?: number;
1270
1298
  /** Accounts that completed without throwing. */
@@ -1535,7 +1563,27 @@ export declare class WorkBuddyScheduler {
1535
1563
  * two hours still runs twice a day — but a job whose hour passed while DSH was
1536
1564
  * closed runs immediately on the next tick instead of waiting for tomorrow.
1537
1565
  */
1538
- private isDue;
1566
+ /**
1567
+ * The configured slot `kind` should consume at `now`, or undefined when none.
1568
+ *
1569
+ * Returns the SLOT STRING (not a boolean) because the caller must record the
1570
+ * same value it acted on. Returning a boolean was the original bug's enabler:
1571
+ * the tick asked "is it due", then `runJob` independently wrote "what time is
1572
+ * it now" — and those two answers were almost never equal.
1573
+ *
1574
+ * CATCH-UP semantics are deliberate: a candidate fires once its hour has
1575
+ * PASSED and its slot is still unconsumed, so a laptop that slept through
1576
+ * 10:00 still runs the job when it wakes, on the same day.
1577
+ *
1578
+ * The EARLIEST unconsumed candidate wins, which is what keeps a two-hour job
1579
+ * (`travelHours: [9, 21]`) whole: consuming the earliest due slot leaves the
1580
+ * later one for its own hour.
1581
+ */
1582
+ private dueSlot;
1583
+ /** Today's consumed slots for one job, as a set. */
1584
+ private firedSlotsOf;
1585
+ /** Record one consumed slot on a job's state. */
1586
+ private consumeSlot;
1539
1587
  private tick;
1540
1588
  /** Run one job against every eligible account and record the outcome. */
1541
1589
  /**
@@ -1568,6 +1616,12 @@ export declare class WorkBuddyScheduler {
1568
1616
  /**
1569
1617
  * Run one job against every eligible account and record the outcome.
1570
1618
  *
1619
+ * `consumedSlot` is the configured slot this run spends (`dueSlot`'s answer).
1620
+ * It is passed IN rather than recomputed here so the value recorded is exactly
1621
+ * the value the schedule decided on — the defect this replaces wrote
1622
+ * `slotKey(now)` instead, i.e. the clock time the run finished at, which
1623
+ * almost never equals the configured candidate the gate had compared against.
1624
+ *
1571
1625
  * The task job runs in TWO passes. The first sends the event chains that light
1572
1626
  * up client-scored tasks; the second collects rewards. They are separate
1573
1627
  * because scoring lands asynchronously — a chain sent and claimed within the
@@ -1965,6 +2019,21 @@ interface PoolWebStatus {
1965
2019
  interface PoolWebAutomationJob {
1966
2020
  /** `YYYY-MM-DD` of the last run in this process, if it has run. */
1967
2021
  lastRunDate?: string;
2022
+ /**
2023
+ * Epoch ms of the last run, so the card can show the TIME.
2024
+ *
2025
+ * Carried because a date-only stamp cannot tell one run from eight: every
2026
+ * repeat inside the same day rendered as the identical `2026-09-28 · 2`,
2027
+ * which is what kept a "re-runs every hour" defect invisible on the card.
2028
+ */
2029
+ lastRunAtMs?: number;
2030
+ /**
2031
+ * Configured slots consumed today, as `YYYY-MM-DDTHH`.
2032
+ *
2033
+ * Shown so "which of today's hours already ran" is answerable at a glance
2034
+ * rather than inferred from a counter.
2035
+ */
2036
+ firedSlots?: readonly string[];
1968
2037
  /** Accounts that finished without error on the last run. */
1969
2038
  ok: number;
1970
2039
  /** Accounts that failed on the last run (each one skipped, the run continued). */
package/lib/index.js CHANGED
@@ -4164,7 +4164,7 @@ var WorkBuddyScheduler = class {
4164
4164
  async runNow(kind, force = false) {
4165
4165
  const today = dayKey(this.now());
4166
4166
  const state = this.states[kind];
4167
- if (!force && state.lastRunSlot === slotKey(this.now())) return { ...state };
4167
+ if (!force && state.lastFiredHour === slotKey(this.now())) return { ...state };
4168
4168
  this.busy = true;
4169
4169
  try {
4170
4170
  await this.runJob(kind, today);
@@ -4262,13 +4262,46 @@ var WorkBuddyScheduler = class {
4262
4262
  * two hours still runs twice a day — but a job whose hour passed while DSH was
4263
4263
  * closed runs immediately on the next tick instead of waiting for tomorrow.
4264
4264
  */
4265
- isDue(kind, now) {
4265
+ /**
4266
+ * The configured slot `kind` should consume at `now`, or undefined when none.
4267
+ *
4268
+ * Returns the SLOT STRING (not a boolean) because the caller must record the
4269
+ * same value it acted on. Returning a boolean was the original bug's enabler:
4270
+ * the tick asked "is it due", then `runJob` independently wrote "what time is
4271
+ * it now" — and those two answers were almost never equal.
4272
+ *
4273
+ * CATCH-UP semantics are deliberate: a candidate fires once its hour has
4274
+ * PASSED and its slot is still unconsumed, so a laptop that slept through
4275
+ * 10:00 still runs the job when it wakes, on the same day.
4276
+ *
4277
+ * The EARLIEST unconsumed candidate wins, which is what keeps a two-hour job
4278
+ * (`travelHours: [9, 21]`) whole: consuming the earliest due slot leaves the
4279
+ * later one for its own hour.
4280
+ */
4281
+ dueSlot(kind, now) {
4266
4282
  const hours = this.hoursOf(kind);
4267
- if (hours.length === 0) return false;
4268
- const { hour } = zonedParts(now);
4269
- const current = Number(hour);
4283
+ if (hours.length === 0) return void 0;
4284
+ const current = Number(zonedParts(now).hour);
4270
4285
  const today = dayKey(now);
4271
- return hours.some((candidate) => candidate <= current && this.states[kind].lastRunSlot !== `${today}T${String(candidate).padStart(2, "0")}`);
4286
+ const fired = this.firedSlotsOf(kind);
4287
+ for (const candidate of [...hours].sort((a, b) => a - b)) {
4288
+ if (candidate > current) break;
4289
+ const slot = `${today}T${String(candidate).padStart(2, "0")}`;
4290
+ if (!fired.has(slot)) return slot;
4291
+ }
4292
+ }
4293
+ /** Today's consumed slots for one job, as a set. */
4294
+ firedSlotsOf(kind) {
4295
+ const stored = this.states[kind].firedSlots;
4296
+ if (stored === void 0) return /* @__PURE__ */ new Set();
4297
+ const today = dayKey(this.now());
4298
+ return new Set(stored.filter((slot) => slot.startsWith(`${today}T`)));
4299
+ }
4300
+ /** Record one consumed slot on a job's state. */
4301
+ consumeSlot(kind, slot) {
4302
+ const state = this.states[kind];
4303
+ const today = dayKey(this.now());
4304
+ state.firedSlots = [.../* @__PURE__ */ new Set([...this.firedSlotsOf(kind), slot])].filter((entry) => entry.startsWith(`${today}T`)).sort();
4272
4305
  }
4273
4306
  async tick() {
4274
4307
  if (!this.enabled || this.stopped || this.busy) return;
@@ -4279,9 +4312,10 @@ var WorkBuddyScheduler = class {
4279
4312
  const today = dayKey(now);
4280
4313
  for (const kind of JOB_KINDS) {
4281
4314
  if (this.stopped) return;
4282
- if (this.states[kind].lastRunSlot === slot) continue;
4283
- if (!this.isDue(kind, now)) continue;
4284
- await this.runJob(kind, today);
4315
+ if (this.states[kind].lastFiredHour === slot) continue;
4316
+ const consumed = this.dueSlot(kind, now);
4317
+ if (consumed === void 0) continue;
4318
+ await this.runJob(kind, today, consumed);
4285
4319
  }
4286
4320
  } catch (error) {
4287
4321
  this.logger.warn?.("dsh-workbuddy-xdpool automation tick failed:", error);
@@ -4367,6 +4401,12 @@ var WorkBuddyScheduler = class {
4367
4401
  /**
4368
4402
  * Run one job against every eligible account and record the outcome.
4369
4403
  *
4404
+ * `consumedSlot` is the configured slot this run spends (`dueSlot`'s answer).
4405
+ * It is passed IN rather than recomputed here so the value recorded is exactly
4406
+ * the value the schedule decided on — the defect this replaces wrote
4407
+ * `slotKey(now)` instead, i.e. the clock time the run finished at, which
4408
+ * almost never equals the configured candidate the gate had compared against.
4409
+ *
4370
4410
  * The task job runs in TWO passes. The first sends the event chains that light
4371
4411
  * up client-scored tasks; the second collects rewards. They are separate
4372
4412
  * because scoring lands asynchronously — a chain sent and claimed within the
@@ -4374,7 +4414,7 @@ var WorkBuddyScheduler = class {
4374
4414
  * while claiming wants the whole pool to have been lit up first. Splitting
4375
4415
  * them costs one shared wait instead of one wait per account.
4376
4416
  */
4377
- async runJob(kind, today) {
4417
+ async runJob(kind, today, consumedSlot) {
4378
4418
  const accountWord = kind === "report" ? "report" : kind;
4379
4419
  let ok = 0;
4380
4420
  let failed = 0;
@@ -4439,7 +4479,8 @@ var WorkBuddyScheduler = class {
4439
4479
  if (index < accounts.length - 1 && this.delayMs > 0 && !this.stopped) await sleep(this.delayMs);
4440
4480
  }
4441
4481
  const state = this.states[kind];
4442
- state.lastRunSlot = slotKey(this.now());
4482
+ if (consumedSlot !== void 0) this.consumeSlot(kind, consumedSlot);
4483
+ state.lastFiredHour = slotKey(this.now());
4443
4484
  state.lastRunDate = today;
4444
4485
  state.lastRunAtMs = this.now().getTime();
4445
4486
  state.ok = ok;
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-workbuddy-xdpool",
3
3
  "displayName": "DSH WorkBuddy XD Pool",
4
4
  "description": "Merge every locally signed-in WorkBuddy account into DeepSeek Harness as one auto-failing-over model pool (multi-account rotation, live credits, daily check-in and model catalog).",
5
- "version": "1.7.1",
5
+ "version": "1.7.2",
6
6
  "license": "MIT",
7
7
  "author": "XDTrees",
8
8
  "homepage": "https://github.com/XDTrees/dsh-workbuddy-xdpool#readme",