mslxdff 0.1.227 → 0.1.229

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.
@@ -45,7 +45,7 @@ export function warmPeer(peer, deps = {}) {
45
45
  export async function serialTryPeers({ candidates, v6Candidates = [], ctx, tried, deps }) {
46
46
  const {
47
47
  resolvePeerTarget, forwardToPeer, peerName, recordLater, summarizeRequest, runHook, errMsg,
48
- perAttemptCap, deadline, budgetMs,
48
+ perAttemptCap, deadline, budgetMs, probePeer,
49
49
  } = deps;
50
50
  const warm = deps.warmPeer || warmPeer;
51
51
  const failed = [];
@@ -71,22 +71,51 @@ export async function serialTryPeers({ candidates, v6Candidates = [], ctx, tried
71
71
  const ipv4Chain = filterSlow(fresh(candidates));
72
72
  const ipv6Chain = filterSlow(fresh(v6Candidates));
73
73
 
74
- // —— 预取:进循环前就把「下一个」定好,并顺手预热连接 ——
74
+ // —— 预取 + 探针(用户 2026-10-20 口径)——
75
+ // 「某个节点当备选的时候打一个 hi,准备轮到它了也打一个 hi;hi 都回不来就当它不行,直接换。」
76
+ // 两个 hi 都打,不做结果复用:备选是上一次切换时定的,中间可能已过了几十秒
77
+ // (上一个节点在大 body 上撞墙,实测 1.5MB 要 28~62s),那时活不代表现在活。
78
+ // 备选那个 hi 的作用是暖连接 + 提前暴露死节点,轮到时的那个才决定去留。
75
79
  let pending = null;
76
- const prime = (peer) => {
80
+ // resolvePeerTarget 会打 /v1/models/status,别为探针白测两次:这里记住解析结果给下一轮复用。
81
+ const resolved = new Map(); // url -> Promise<{peer,target}|null>
82
+ const resolveOnce = (peer) => {
83
+ const hit = resolved.get(peer.url);
84
+ if (hit) return hit;
85
+ const p = Promise.resolve()
86
+ .then(() => resolvePeerTarget(ctx, peer))
87
+ .catch(() => null);
88
+ resolved.set(peer.url, p);
89
+ return p;
90
+ };
91
+ const prime = (peer, target) => {
92
+ if (!peer || !target || typeof probePeer !== "function") return;
93
+ try {
94
+ const p = probePeer(peer, target);
95
+ if (p && typeof p.then === "function") p.catch(() => {});
96
+ } catch { /* 预热失败不影响主流程 */ }
97
+ };
98
+ const primeAt = (peer) => {
77
99
  if (!peer) return;
78
- warm(peer, deps.warmPeerDeps);
100
+ resolveOnce(peer)
101
+ .then((r) => { if (r) prime(r.peer, r.target); })
102
+ .catch(() => {});
79
103
  };
80
104
  const takeNext = (stage) => {
81
105
  const chain = stage === "ipv6" ? ipv6Chain : ipv4Chain;
82
106
  const p = chain.shift() || null;
83
- if (p) prime(chain[0] || null); // 立即预热「再下一个」,保证永远有备选就绪
107
+ // 立刻给「再下一个」打 hi。模型名要现在解析——下一轮循环该它时 ctx.model 可能已变。
108
+ if (p) primeAt(chain[0] || null);
84
109
  return p;
85
110
  };
86
111
 
87
112
  let stage = "ipv4";
88
113
  let emptyIpv4Announced = false;
89
114
 
115
+ // 链头从没当过「备选」(它一上来就是主角),进入循环前先给它也打一个 hi ——
116
+ // 此时 ctx.model 还是本机刚失败的那个模型,解析目标与后面轮到时同源。
117
+ primeAt(ipv4Chain[0] || null);
118
+
90
119
  for (;;) {
91
120
  if (deadline > 0 && Date.now() >= deadline) {
92
121
  const remaining = ipv4Chain.length + ipv6Chain.length;
@@ -110,12 +139,33 @@ export async function serialTryPeers({ candidates, v6Candidates = [], ctx, tried
110
139
  }
111
140
  if (!pending) break;
112
141
 
113
- const prepared = await resolvePeerTarget(ctx, pending);
142
+ const prepared = await resolveOnce(pending);
114
143
  if (!prepared) continue;
115
144
  const { peer, target } = prepared;
116
145
  tried.add(peer.url);
117
146
  const ctrl = new AbortController();
118
147
  const name = peerName(peer);
148
+
149
+ // 轮到它了:再打一个 hi。回不来就当这个备选不行,直接换下一个。
150
+ if (typeof probePeer === "function") {
151
+ const t = performance.now();
152
+ let r;
153
+ try { r = await probePeer(peer, target); } catch { r = { ok: false }; }
154
+ const probeMs = Math.round(performance.now() - t);
155
+ if (r && r.ok === false) {
156
+ ctx.evt("peer-probe", { peer: peer.url, name, model: target, phase: "turn", ok: false, status: r.status ?? null, latencyMs: probeMs, action: "skip" });
157
+ failed.push({ ok: false, peer, target, res: null, status: r.status || 502 });
158
+ recordLater(() => ctx.peers.recordError(peer.url, { status: r.status || 502 }));
159
+ recordLater(() => ctx.peers.recordResult(peer.url, { ok: false }));
160
+ continue;
161
+ }
162
+ ctx.evt("peer-probe", { peer: peer.url, name, model: target, phase: "turn", ok: true, status: r?.status ?? null, latencyMs: probeMs });
163
+ if (deadline > 0 && Date.now() >= deadline) {
164
+ ctx.evt("peer-budget-out", { peer: null, reason: `peer 阶段总预算 ${budgetMs}ms 用尽(探针后)`, budgetMs, remaining: ipv4Chain.length + ipv6Chain.length });
165
+ break;
166
+ }
167
+ }
168
+
119
169
  ctx.evt("peer-request", { peer: peer.url, name, model: target, hops: ctx.hops + 1, payload: summarizeRequest(ctx.body), serial: 1, stage });
120
170
  if (ctx.plugins?.length) {
121
171
  runHook(ctx.plugins, "peer:beforeForward", { reqId: ctx.reqId, peer: peer.url, model: target, hops: ctx.hops + 1 }).catch(() => {});
@@ -5,6 +5,7 @@ import { runHook } from "../plugins.js";
5
5
  import { buildShareKeysHeader, SHARE_KEYS_HEADER } from "../providers/share-keys.js";
6
6
  import { compatFetch, timeoutSignal, getUndici } from "../compat.js";
7
7
  import { serialTryPeers, PEER_SERIAL } from "./peer-serial.js";
8
+ import { probePeer, peerProbeTimeoutMs } from "./peer-probe.js";
8
9
  import { overForwardLimit, contextGuardMessage } from "../chat-pipeline/context-guard.js";
9
10
 
10
11
  // peer 阶段总预算:三批候选(ordered / orderedByLastError / coolingByLastError)共享同一个
@@ -252,6 +253,13 @@ export async function racePeerCandidates(candidates, ctx) {
252
253
  perAttemptCap: peerAttemptTimeoutMs(), deadline, budgetMs,
253
254
  // 连接预热:对「下一个备选」提前打 /health,复用 chat 的同一连接池(见 warmPeerHealth 注释)
254
255
  warmPeer: (peer) => { if (peer?.url) warmPeerHealth(peer.url, { timeoutMs: 1500 }).catch(() => {}); },
256
+ // 用户 2026-10-20:备选时打一个 hi、轮到它时再打一个 hi,hi 回不来就换下一个。
257
+ // 走 getPeerDispatcher 的**同一条连接池**(与 chat 转发复用 socket,裸 fetch 热的是另一条等于白热)。
258
+ probePeer: (peer, model) => probePeer(peer, {
259
+ model: model || null,
260
+ timeoutMs: peerProbeTimeoutMs(),
261
+ dispatcher: getPeerDispatcher(),
262
+ }),
255
263
  },
256
264
  });
257
265
  }
@@ -1,126 +1,175 @@
1
- /**
2
- * raccoon 登录奖励的 daemon 兜底扫描(仿 src/runtime/qoder-checkin.js 的排程形态)。
3
- * 开关:MSLXDFF_RACCOON_CHECKIN=0 关闭(默认开);时间:MSLXDFF_RACCOON_CHECKIN_HOUR(默认 9 点本地时)。
4
- * ⚠ 性质纠偏(2026-10-10 账单实测):**这家没有"每日签到"可领** ——
5
- * 每日 300 由服务端自动发放且当天 23:59:59 清零(无端点);唯一要领的是「桌面端登录奖励 3000」,**每号一次性**。
6
- * 正常路径是 `raccoon login` 成功后立刻领掉(那时 token 最新鲜);本任务只是兜底:
7
- * 扫出"历史上没领过"的号补领一次,领过的号每天只花一次账单查询即判"已领",不再打写端点。
8
- */
9
- import { todayKey, nextRunDelayMs, shouldCatchUp } from "./workbuddy-checkin.js";
10
-
11
- export function isCheckinEnabled(env = process.env) {
12
- const v = String(env.MSLXDFF_RACCOON_CHECKIN ?? "1").trim().toLowerCase();
13
- return !["0", "false", "off", "no"].includes(v);
14
- }
15
-
16
- export function getCheckinHour(env = process.env) {
17
- const h = Number(env.MSLXDFF_RACCOON_CHECKIN_HOUR);
18
- return Number.isInteger(h) && h >= 0 && h <= 23 ? h : 9;
19
- }
20
-
21
- /** 账号文档 → credits.js 认的 credential 形状(与 provider 内 credentialFor 同构)。 */
22
- export function credentialFromAccountDoc(doc) {
23
- return {
24
- access_token: doc?.accessToken || "",
25
- refresh_token: doc?.refreshToken || "",
26
- // ⚠ expires_at 必须带上:少了它,isRaccoonExpired / isRaccoonExpiringSoon 一律判不出来(只能回落去解 JWT,
27
- // 而假 token 解不出 → undefined → 保守判「未过期」),结果就是过期号照发请求去撞 401、临期号该续不续。
28
- expires_at: doc?.expiresAt || "",
29
- office_identity: doc?.officeIdentity || "",
30
- device_id: doc?.deviceId || "",
31
- uid: doc?.uid || "",
32
- };
33
- }
34
-
35
- export async function setupRaccoonCheckin({ bus, logs, fetchImpl, dirs, env = process.env, now } = {}) {
36
- function emit(type, data = {}) {
37
- const entry = { ts: Date.now(), type, ...data };
38
- try { bus?.emit(entry); } catch {}
39
- try { logs?.appendEvent?.(entry); } catch {}
40
- try { console.log(`[raccoon-checkin] ${type} ${JSON.stringify(data)}`.slice(0, 500)); } catch {}
41
- }
42
- if (!isCheckinEnabled(env)) {
43
- console.log("[raccoon-checkin] disabled (set MSLXDFF_RACCOON_CHECKIN=1 to enable)");
44
- emit("raccoon-checkin-disabled");
45
- return { enabled: false };
46
- }
47
- const hour = getCheckinHour(env);
48
- console.log(`[raccoon-checkin] enabled: daily ${String(hour).padStart(2, "0")}:00 local`);
49
- emit("raccoon-checkin-enabled", { hour });
50
-
51
- const stateMod = await import("../state.js");
52
- const { listRaccoonAccountDocs } = await import("../providers/raccoon/account-store.js");
53
- const { claimRaccoonLoginReward, raccoonClaimStatus } = await import("../providers/raccoon/credits.js");
54
- const { isRaccoonExpired } = await import("../providers/raccoon/auth.js");
55
-
56
- let running = false;
57
- async function runOnce(reason) {
58
- if (running) return { skipped: "already-running" };
59
- running = true;
60
- try {
61
- const accounts = listRaccoonAccountDocs(dirs ? { dirs } : {}).filter((d) => d.accessToken);
62
- if (!accounts.length) {
63
- emit("raccoon-checkin-no-accounts", { reason });
64
- return { ok: false, reason: "no-accounts" };
65
- }
66
- const results = [];
67
- for (const a of accounts) {
68
- const uid = String(a.uid).slice(0, 8);
69
- const credential = credentialFromAccountDoc(a);
70
- // access_token 只有约 3 小时寿命。过期的号一律跳过,**绝不去碰 refresh**:网关请求可能正在同一时刻
71
- // 续同一个号,而 refresh_token 是一次性轮换的,两边抢同一条 token 等于亲手把号烧掉。
72
- if (isRaccoonExpired(credential)) {
73
- results.push({ uid, ok: false, claimed: false, status: "auth_stale", msg: "登录态已过期,需重新扫码:mslxdff -provider raccoon login" });
74
- continue;
75
- }
76
- try {
77
- const r = await claimRaccoonLoginReward({
78
- credential,
79
- ...(fetchImpl ? { fetchImpl } : {}),
80
- env,
81
- });
82
- // 四种答复必须分开显示:只有 claimed 是真到账;already 是一次性礼包领过了(正常态);
83
- // phantom = 上游回 code:0 但账单没这笔(实测行为);unverified = 账单查不动,不下结论。
84
- const status = raccoonClaimStatus(r);
85
- results.push({ uid, ok: status === "claimed" || status === "already", claimed: r.claimed, status, points: r.points });
86
- } catch (e) {
87
- results.push({ uid, ok: false, claimed: false, status: "error", msg: String(e?.message || e).slice(0, 120) });
88
- }
89
- }
90
- const got = results.filter((r) => r.status === "claimed").length;
91
- const already = results.filter((r) => r.status === "already").length;
92
- const date = todayKey(now === undefined ? new Date() : new Date(now));
93
- try {
94
- stateMod.writeStateImmediate(stateMod.defaultStateFile(), {
95
- raccoonCheckin: { date, at: Date.now(), ok: got + already, claimed: got, already, total: results.length, accounts: results },
96
- });
97
- } catch {}
98
- for (const r of results) emit("raccoon-checkin-account", { ...r, reason });
99
- emit("raccoon-checkin-done", { date, ok: got + already, claimed: got, already, total: results.length, reason });
100
- return { ok: got + already > 0, claimed: got, already, total: results.length, results };
101
- } catch (e) {
102
- emit("raccoon-checkin-failed", { error: String(e?.message || e).slice(0, 200), reason });
103
- return { ok: false, error: String(e?.message || e).slice(0, 200) };
104
- } finally {
105
- running = false;
106
- }
107
- }
108
-
109
- // 启动补签:今天已过计划点、且(从没签过 或 上次不是今天)→ 90s 后跑(等网络/上游就绪)
110
- try {
111
- const lastDate = stateMod.readState(stateMod.defaultStateFile())?.raccoonCheckin?.date || "";
112
- if (shouldCatchUp({ lastDate, hour })) {
113
- setTimeout(() => { runOnce("catch-up").catch(() => {}); }, 90_000).unref?.();
114
- console.log(`[raccoon-checkin] catch-up scheduled (last=${lastDate || "never"})`);
115
- }
116
- } catch {}
117
- // 每日定时:对齐到下一个计划点,跑完再约 24h 后
118
- function arm() {
119
- const delay = nextRunDelayMs(new Date(), hour);
120
- setTimeout(() => {
121
- runOnce("daily").catch(() => {}).finally(arm);
122
- }, delay).unref?.();
123
- }
124
- arm();
125
- return { enabled: true, hour, runOnce };
126
- }
1
+ /**
2
+ * raccoon 每日任务:**每日积分结算**(每天必做)+ 登录奖励补领(每号一次性)。
3
+ * 开关:MSLXDFF_RACCOON_CHECKIN=0 关闭(默认开);时间:MSLXDFF_RACCOON_CHECKIN_HOUR(默认 9 点本地时)。
4
+ *
5
+ * ⚠ 性质纠偏(2026-10-21 账单实测,推翻 2026-10-10 的旧结论):
6
+ * 这家的每日积分是**惰性结算**——服务端按天挂账,当天第一次访问 `/api/web/office/v3/setting_info`
7
+ * 才真正入账(入账即有效期到 T+6 的 23:59:59,即官方文案「当天登录后延长至 7 天」)。
8
+ * 不访问该端点,当天 `daily_points` 恒 0、账单里连 `daily_grant` 都没有。
9
+ * 所以本任务每天必做的第一件事就是打这个端点(幂等,连打多次只入账一次)。
10
+ * 第二件事才是「桌面端登录奖励 3000」的补领:**每号一次性**,正常路径是 `raccoon login` 时领掉,
11
+ * 本任务只是兜底:扫出"历史上没领过"的号补领一次,领过的号每天只花一次账单查询即判"已领"。
12
+ */
13
+ import { todayKey, nextRunDelayMs, shouldCatchUp } from "./workbuddy-checkin.js";
14
+
15
+ export function isCheckinEnabled(env = process.env) {
16
+ const v = String(env.MSLXDFF_RACCOON_CHECKIN ?? "1").trim().toLowerCase();
17
+ return !["0", "false", "off", "no"].includes(v);
18
+ }
19
+
20
+ export function getCheckinHour(env = process.env) {
21
+ const h = Number(env.MSLXDFF_RACCOON_CHECKIN_HOUR);
22
+ return Number.isInteger(h) && h >= 0 && h <= 23 ? h : 9;
23
+ }
24
+
25
+ /** 账号文档 → credits.js 认的 credential 形状(与 provider 内 credentialFor 同构)。 */
26
+ export function credentialFromAccountDoc(doc) {
27
+ return {
28
+ access_token: doc?.accessToken || "",
29
+ refresh_token: doc?.refreshToken || "",
30
+ // ⚠ expires_at 必须带上:少了它,isRaccoonExpired / isRaccoonExpiringSoon 一律判不出来(只能回落去解 JWT,
31
+ // 而假 token 解不出 → undefined → 保守判「未过期」),结果就是过期号照发请求去撞 401、临期号该续不续。
32
+ expires_at: doc?.expiresAt || "",
33
+ office_identity: doc?.officeIdentity || "",
34
+ device_id: doc?.deviceId || "",
35
+ uid: doc?.uid || "",
36
+ };
37
+ }
38
+
39
+ export async function setupRaccoonCheckin({ bus, logs, fetchImpl, dirs, env = process.env, now } = {}) {
40
+ function emit(type, data = {}) {
41
+ const entry = { ts: Date.now(), type, ...data };
42
+ try { bus?.emit(entry); } catch {}
43
+ try { logs?.appendEvent?.(entry); } catch {}
44
+ try { console.log(`[raccoon-checkin] ${type} ${JSON.stringify(data)}`.slice(0, 500)); } catch {}
45
+ }
46
+ if (!isCheckinEnabled(env)) {
47
+ console.log("[raccoon-checkin] disabled (set MSLXDFF_RACCOON_CHECKIN=1 to enable)");
48
+ emit("raccoon-checkin-disabled");
49
+ return { enabled: false };
50
+ }
51
+ const hour = getCheckinHour(env);
52
+ console.log(`[raccoon-checkin] enabled: daily ${String(hour).padStart(2, "0")}:00 local`);
53
+ emit("raccoon-checkin-enabled", { hour });
54
+
55
+ const stateMod = await import("../state.js");
56
+ const { listRaccoonAccountDocs } = await import("../providers/raccoon/account-store.js");
57
+ const { claimRaccoonDailyPoints, claimRaccoonLoginReward, raccoonClaimStatus, raccoonDailyStatus } = await import("../providers/raccoon/credits.js");
58
+ const { isRaccoonExpired } = await import("../providers/raccoon/auth.js");
59
+
60
+ let running = false;
61
+ async function runOnce(reason) {
62
+ if (running) return { skipped: "already-running" };
63
+ running = true;
64
+ try {
65
+ const accounts = listRaccoonAccountDocs(dirs ? { dirs } : {}).filter((d) => d.accessToken);
66
+ if (!accounts.length) {
67
+ emit("raccoon-checkin-no-accounts", { reason });
68
+ return { ok: false, reason: "no-accounts" };
69
+ }
70
+ const results = [];
71
+ for (const a of accounts) {
72
+ const uid = String(a.uid).slice(0, 8);
73
+ const credential = credentialFromAccountDoc(a);
74
+ // 登录态过期的号一律跳过,**绝不去碰 refresh**:网关请求可能正在同一时刻
75
+ // 续同一个号,而 refresh_token 是一次性轮换的,两边抢同一条 token 等于亲手把号烧掉。
76
+ // (实测 access_token 寿命是 30 天而非注释里旧写的 3 小时,所以正常情况下每日任务不会撞上这条)
77
+ if (isRaccoonExpired(credential)) {
78
+ results.push({ uid, ok: false, claimed: false, daily: "auth_stale", status: "auth_stale", msg: "登录态已过期,需重新扫码:mslxdff -provider raccoon login" });
79
+ continue;
80
+ }
81
+
82
+ // 第一件:每日积分结算(惰性钩子,幂等)。失败不拖累下面的补领 —— 两件事互不依赖。
83
+ let daily = "error";
84
+ let dailyPoints = 0;
85
+ try {
86
+ const d = await claimRaccoonDailyPoints({ credential, ...(fetchImpl ? { fetchImpl } : {}), env, ...(now === undefined ? {} : { now }) });
87
+ daily = raccoonDailyStatus(d);
88
+ dailyPoints = d.points || 0;
89
+ } catch {
90
+ daily = "error";
91
+ }
92
+
93
+ // 第二件:一次性登录奖励补领。四种答复必须分开显示:只有 claimed 是真到账;
94
+ // already 是一次性礼包领过了(正常态);phantom = 上游回 code:0 但账单没这笔(实测行为);
95
+ // unverified = 账单查不动,不下结论。
96
+ let reward = "error";
97
+ let rewardPoints = 0;
98
+ let msg;
99
+ try {
100
+ const r = await claimRaccoonLoginReward({ credential, ...(fetchImpl ? { fetchImpl } : {}), env });
101
+ reward = raccoonClaimStatus(r);
102
+ rewardPoints = r.points || 0;
103
+ } catch (e) {
104
+ msg = String(e?.message || e).slice(0, 120);
105
+ }
106
+
107
+ results.push({
108
+ uid,
109
+ ok: daily === "claimed" || daily === "already",
110
+ daily,
111
+ dailyPoints,
112
+ reward,
113
+ rewardPoints,
114
+ status: daily === "auth_stale" ? "auth_stale" : daily,
115
+ ...(msg === undefined ? {} : { msg }),
116
+ });
117
+ }
118
+ const dailyGot = results.filter((r) => r.daily === "claimed").length;
119
+ const dailyDone = results.filter((r) => r.daily === "claimed" || r.daily === "already").length;
120
+ const rewardGot = results.filter((r) => r.reward === "claimed").length;
121
+ const already = results.filter((r) => r.reward === "already").length;
122
+ const date = todayKey(now === undefined ? new Date() : new Date(now));
123
+ try {
124
+ stateMod.writeStateImmediate(stateMod.defaultStateFile(), {
125
+ raccoonCheckin: {
126
+ date,
127
+ at: Date.now(),
128
+ ok: dailyDone,
129
+ dailyClaimed: dailyGot,
130
+ dailyDone,
131
+ claimed: rewardGot,
132
+ already,
133
+ total: results.length,
134
+ accounts: results,
135
+ },
136
+ });
137
+ } catch {}
138
+ for (const r of results) emit("raccoon-checkin-account", { ...r, reason });
139
+ emit("raccoon-checkin-done", {
140
+ date,
141
+ ok: dailyDone,
142
+ dailyClaimed: dailyGot,
143
+ dailyDone,
144
+ claimed: rewardGot,
145
+ already,
146
+ total: results.length,
147
+ reason,
148
+ });
149
+ return { ok: dailyDone > 0, dailyClaimed: dailyGot, dailyDone, claimed: rewardGot, already, total: results.length, results };
150
+ } catch (e) {
151
+ emit("raccoon-checkin-failed", { error: String(e?.message || e).slice(0, 200), reason });
152
+ return { ok: false, error: String(e?.message || e).slice(0, 200) };
153
+ } finally {
154
+ running = false;
155
+ }
156
+ }
157
+
158
+ // 启动补签:今天已过计划点、且(从没签过 或 上次不是今天)→ 90s 后跑(等网络/上游就绪)
159
+ try {
160
+ const lastDate = stateMod.readState(stateMod.defaultStateFile())?.raccoonCheckin?.date || "";
161
+ if (shouldCatchUp({ lastDate, hour })) {
162
+ setTimeout(() => { runOnce("catch-up").catch(() => {}); }, 90_000).unref?.();
163
+ console.log(`[raccoon-checkin] catch-up scheduled (last=${lastDate || "never"})`);
164
+ }
165
+ } catch {}
166
+ // 每日定时:对齐到下一个计划点,跑完再约 24h 后
167
+ function arm() {
168
+ const delay = nextRunDelayMs(new Date(), hour);
169
+ setTimeout(() => {
170
+ runOnce("daily").catch(() => {}).finally(arm);
171
+ }, delay).unref?.();
172
+ }
173
+ arm();
174
+ return { enabled: true, hour, runOnce };
175
+ }