@sleep2agi/commhub-server 0.9.0-preview.100 → 0.9.0-preview.101

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.100",
3
+ "version": "0.9.0-preview.101",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/db.ts CHANGED
@@ -1596,6 +1596,12 @@ try { db.exec("ALTER TABLE scheduled_tasks ADD COLUMN misfire_policy TEXT NOT NU
1596
1596
  // whether the node had ever picked it up: not_received | in_progress.
1597
1597
  try { db.exec("ALTER TABLE scheduled_task_runs ADD COLUMN blocked_by_task_id TEXT"); } catch {}
1598
1598
  try { db.exec("ALTER TABLE scheduled_task_runs ADD COLUMN blocked_by_state TEXT"); } catch {}
1599
+ // #523 — consecutive-failure alert bookkeeping (scheduled-failures.ts). Additive and nullable:
1600
+ // an older Hub never reads them, so rolling back is safe. failure_alert_at = ISO time of the last
1601
+ // alert; failure_alert_key = the last successful run_id at that moment ('' = none yet), so a
1602
+ // success in between re-arms the alert.
1603
+ try { db.exec("ALTER TABLE scheduled_tasks ADD COLUMN failure_alert_at TEXT"); } catch {}
1604
+ try { db.exec("ALTER TABLE scheduled_tasks ADD COLUMN failure_alert_key TEXT"); } catch {}
1599
1605
 
1600
1606
  // RFC-036 / B4 — owner-authorized edits of node-host managed schedules.
1601
1607
  // This is intentionally separate from Hub scheduled_tasks: these rows are
@@ -1986,6 +1992,18 @@ export function chainReplyToParent(
1986
1992
 
1987
1993
  const SCHEDULED_RUN_TERMINAL_STATUSES = new Set(["replied", "failed", "cancelled", "expired"]);
1988
1994
 
1995
+ // #523 — called after a run row is closed by its task's terminal state. Registered by
1996
+ // scheduled-failures.ts (a hook, not an import: that module depends on db.ts). Never throws.
1997
+ type ScheduledRunTerminalHook = (scheduleId: string, status: string) => void;
1998
+ let scheduledRunTerminalHook: ScheduledRunTerminalHook | null = null;
1999
+ export function setScheduledRunTerminalHook(fn: ScheduledRunTerminalHook | null): void { scheduledRunTerminalHook = fn; }
2000
+ function fireScheduledRunTerminalHook(scheduleId: string, status: string): void {
2001
+ if (!scheduledRunTerminalHook) return;
2002
+ try { scheduledRunTerminalHook(scheduleId, status); } catch (e: any) {
2003
+ console.error(`[scheduled-tasks] run terminal hook failed schedule=${scheduleId}: ${e?.message || e}`);
2004
+ }
2005
+ }
2006
+
1989
2007
  type ScheduledTaskLifecycleRow = {
1990
2008
  task_id: string;
1991
2009
  network_id: string | null;
@@ -2041,6 +2059,7 @@ export function syncScheduledRunForTask(taskId: string, expectedNetworkId?: stri
2041
2059
  WHERE run_id = ?4 AND task_id = ?5 AND network_id = ?6 AND schedule_id = ?7`,
2042
2060
  [task.status, errorCode, task.completed_at, binding.run_id, task.task_id, task.network_id, binding.schedule_id],
2043
2061
  );
2062
+ if (updated.changes === 1) fireScheduledRunTerminalHook(binding.schedule_id, task.status);
2044
2063
  return { matched: updated.changes === 1, status: task.status };
2045
2064
  }
2046
2065
 
@@ -19,6 +19,9 @@ process.env.HOST = "127.0.0.1";
19
19
  delete process.env.COMMHUB_DUE_REMINDER_TZ;
20
20
  delete process.env.COMMHUB_DUE_OVERDUE_MAX_DAYS;
21
21
  delete process.env.COMMHUB_DUE_REMINDER_NETWORKS;
22
+ delete process.env.COMMHUB_DUE_REMINDERS;
23
+ delete process.env.COMMHUB_DUE_REMINDERS_NETWORKS;
24
+ delete process.env.COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS;
22
25
  // 节点提醒默认关;这个文件的大部分用例要看节点那一路,先打开(「默认关」单独一个用例)。
23
26
  process.env.COMMHUB_DUE_REMINDER_NODES = "1";
24
27
  const PW = "DueRemindPassw0rd!x";
@@ -335,6 +338,131 @@ describe("上线护栏", () => {
335
338
  expect(sentFor(due.runDueReminders({ now: T0 + 3600_000 }), old)).toEqual([]);
336
339
  } finally { delete process.env.COMMHUB_DUE_REMINDER_NETWORKS; }
337
340
  });
341
+
342
+ // ── #524 按网络开关 ────────────────────────────────────
343
+ const SCOPE_VARS = ["COMMHUB_DUE_REMINDERS", "COMMHUB_DUE_REMINDERS_NETWORKS", "COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS", "COMMHUB_DUE_REMINDER_NETWORKS"];
344
+ async function withEnv<T>(vars: Record<string, string>, fn: () => T | Promise<T>): Promise<T> {
345
+ const saved = Object.fromEntries(SCOPE_VARS.map((k) => [k, process.env[k]]));
346
+ for (const k of SCOPE_VARS) delete process.env[k];
347
+ Object.assign(process.env, vars);
348
+ try { return await fn(); } finally {
349
+ for (const k of SCOPE_VARS) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]!; }
350
+ }
351
+ }
352
+ const baselineCount = (net: string) => db.get("SELECT COUNT(*) AS n FROM requirement_due_reminders WHERE kind = 'baseline' AND network_id = ?1", net).n;
353
+ const rowCount = (id: string) => db.get("SELECT COUNT(*) AS n FROM requirement_due_reminders WHERE requirement_id = ?1", id).n;
354
+
355
+ test("#524 范围解析:各种组合 / 空白值 / 只绑本分支用到的参数", async () => {
356
+ const S = (vars: Record<string, string>) => withEnv(vars, () => due.dueReminderScope());
357
+ // 都不设 = 原样:所有网络
358
+ expect(await S({})).toEqual({ mode: "all", exclude: [] });
359
+ expect(await S({ COMMHUB_DUE_REMINDERS: "1" })).toEqual({ mode: "all", exclude: [] });
360
+ expect(await S({ COMMHUB_DUE_REMINDERS: "0" })).toEqual({ mode: "off" });
361
+ // 旧名白名单不能在 =0 时打开(行为不变)
362
+ expect(await S({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDER_NETWORKS: "net_a" })).toEqual({ mode: "off" });
363
+ expect(await S({ COMMHUB_DUE_REMINDER_NETWORKS: "net_a" })).toEqual({ mode: "only", include: ["net_a"], exclude: [] });
364
+ // 新名白名单:=0 时也打开,只给名单里的
365
+ expect(await S({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_NETWORKS: " net_a , net_b,net_a, " })).toEqual({ mode: "only", include: ["net_a", "net_b"], exclude: [] });
366
+ expect(await S({ COMMHUB_DUE_REMINDERS: "1", COMMHUB_DUE_REMINDERS_NETWORKS: "net_a" })).toEqual({ mode: "only", include: ["net_a"], exclude: [] });
367
+ // 黑名单
368
+ expect(await S({ COMMHUB_DUE_REMINDERS: "1", COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS: "net_x" })).toEqual({ mode: "all", exclude: ["net_x"] });
369
+ expect(await S({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS: "net_x" })).toEqual({ mode: "off" });
370
+ expect(await S({ COMMHUB_DUE_REMINDERS_NETWORKS: "net_a,net_x", COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS: "net_x" })).toEqual({ mode: "only", include: ["net_a"], exclude: ["net_x"] });
371
+ // 空 / 空白 / 只有逗号 = 没设
372
+ expect(await S({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_NETWORKS: "" })).toEqual({ mode: "off" });
373
+ expect(await S({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_NETWORKS: " , , " })).toEqual({ mode: "off" });
374
+ expect(await S({ COMMHUB_DUE_REMINDERS_NETWORKS: " ", COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS: " , " })).toEqual({ mode: "all", exclude: [] });
375
+ // SQL:每个分支只 push 自己用到的参数
376
+ const p1: unknown[] = ["lo", "hi"];
377
+ expect(due.dueScopeSql({ mode: "all", exclude: [] }, p1)).toBe("");
378
+ expect(p1).toEqual(["lo", "hi"]);
379
+ const p2: unknown[] = ["lo", "hi"];
380
+ expect(due.dueScopeSql({ mode: "all", exclude: ["net_x"] }, p2)).toBe(" AND network_id NOT IN (?3)");
381
+ expect(p2).toEqual(["lo", "hi", "net_x"]);
382
+ const p3: unknown[] = ["lo", "hi"];
383
+ expect(due.dueScopeSql({ mode: "only", include: ["net_a", "net_b"], exclude: [] }, p3)).toBe(" AND network_id IN (?3, ?4)");
384
+ expect(p3).toEqual(["lo", "hi", "net_a", "net_b"]);
385
+ const p4: unknown[] = [];
386
+ expect(due.dueScopeSql({ mode: "only", include: [], exclude: ["net_x"] }, p4)).toBeNull();
387
+ expect(due.dueScopeSql({ mode: "off" }, p4)).toBeNull();
388
+ expect(p4).toEqual([]);
389
+ });
390
+
391
+ test("#524 COMMHUB_DUE_REMINDERS=0 + 白名单:只有白名单网络收到;别的网络的行连基线都不写", async () => {
392
+ const other = mkNet("allow_other");
393
+ const mine = await card("dr-524-白名单内", "2026-10-11");
394
+ const theirs = await cardIn(other, "dr-524-白名单外", "2026-10-11");
395
+ const out = await withEnv({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_NETWORKS: ` ${NET} ,` }, () => due.runDueReminders({ now: T0 }));
396
+ expect(sentFor(out, mine)).toEqual(["due_soon"]);
397
+ expect(userNotices(mine)[owen.id]).toHaveLength(1);
398
+ expect(sentFor(out, theirs)).toEqual([]);
399
+ expect(rowCount(theirs)).toBe(0);
400
+ expect(baselineCount(other.net)).toBe(0);
401
+ // 对照:=0 不带白名单 → 整个关掉,谁都不发
402
+ const fresh = await card("dr-524-全关", "2026-10-11");
403
+ const off = await withEnv({ COMMHUB_DUE_REMINDERS: "0" }, () => due.runDueReminders({ now: T0 }));
404
+ expect(off).toEqual([]);
405
+ expect(rowCount(fresh)).toBe(0);
406
+ // 白名单空白 = 没设 → 仍然关
407
+ expect(await withEnv({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_NETWORKS: " , " }, () => due.runDueReminders({ now: T0 }))).toEqual([]);
408
+ });
409
+
410
+ test("#524 黑名单:功能开着时被排除的网络不发、不写基线;其余照发", async () => {
411
+ const other = mkNet("excl_other");
412
+ const mine = await card("dr-524-未排除", "2026-10-11");
413
+ const theirs = await cardIn(other, "dr-524-被排除", "2026-10-11");
414
+ const out = await withEnv({ COMMHUB_DUE_REMINDERS: "1", COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS: other.net }, () => due.runDueReminders({ now: T0 }));
415
+ expect(sentFor(out, mine)).toEqual(["due_soon"]);
416
+ expect(sentFor(out, theirs)).toEqual([]);
417
+ expect(rowCount(theirs)).toBe(0);
418
+ expect(baselineCount(other.net)).toBe(0);
419
+ // 白名单里也点了名 → 排除优先;白名单只剩它 → 什么都不扫
420
+ const both = mkNet("excl_both");
421
+ const c = await cardIn(both, "dr-524-白名单又被排除", "2026-10-11");
422
+ const out2 = await withEnv({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_NETWORKS: both.net, COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS: both.net }, () => due.runDueReminders({ now: T0 }));
423
+ expect(out2).toEqual([]);
424
+ expect(rowCount(c)).toBe(0);
425
+ expect(baselineCount(both.net)).toBe(0);
426
+ });
427
+
428
+ test("#524 默认不变:两个新变量都不设 → 所有网络照发;黑名单空白 = 没设", async () => {
429
+ const other = mkNet("dflt_other");
430
+ const mine = await card("dr-524-默认", "2026-10-11");
431
+ const theirs = await cardIn(other, "dr-524-默认别的网络", "2026-10-11");
432
+ const out = await withEnv({}, () => due.runDueReminders({ now: T0 }));
433
+ expect(sentFor(out, mine)).toEqual(["due_soon"]);
434
+ expect(sentFor(out, theirs)).toEqual(["due_soon"]);
435
+ const other2 = mkNet("dflt_blank");
436
+ const theirs2 = await cardIn(other2, "dr-524-黑名单空白", "2026-10-11");
437
+ const out2 = await withEnv({ COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS: " " }, () => due.runDueReminders({ now: T0 }));
438
+ expect(sentFor(out2, theirs2)).toEqual(["due_soon"]);
439
+ });
440
+
441
+ test("#524 启动:=0 不起定时器;=0 + 白名单起;各打一行范围日志(只有网络 id)", async () => {
442
+ const logs: string[] = [];
443
+ const g = globalThis as any;
444
+ const orig = { log: console.log, setTimeout: g.setTimeout, setInterval: g.setInterval };
445
+ // 不起真定时器(否则 30 秒后会用真时钟扫一遍,干扰后面的用例):只记下调了几次。
446
+ let timers = 0;
447
+ console.log = (...a: unknown[]) => { logs.push(a.map(String).join(" ")); };
448
+ g.setTimeout = () => { timers++; return { unref() {} }; };
449
+ g.setInterval = () => { timers++; return { fake: true }; };
450
+ try {
451
+ const off = await withEnv({ COMMHUB_DUE_REMINDERS: "0" }, () => due.startDueReminderTimer());
452
+ expect(off).toBeNull();
453
+ expect(timers).toBe(0);
454
+ const on = await withEnv({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_NETWORKS: "net_placeholder_a" }, () => due.startDueReminderTimer());
455
+ expect(on).toEqual({ fake: true } as any);
456
+ expect(timers).toBe(2);
457
+ await withEnv({ COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS: "net_placeholder_x" }, () => due.startDueReminderTimer());
458
+ } finally { console.log = orig.log; g.setTimeout = orig.setTimeout; g.setInterval = orig.setInterval; }
459
+ const scopeLogs = logs.filter((l) => l.startsWith("[due-reminders] scope:"));
460
+ expect(scopeLogs).toEqual([
461
+ "[due-reminders] scope: off",
462
+ "[due-reminders] scope: only networks net_placeholder_a",
463
+ "[due-reminders] scope: all networks except net_placeholder_x",
464
+ ]);
465
+ });
338
466
  });
339
467
 
340
468
  describe("#494 列表筛选", () => {
@@ -21,6 +21,11 @@
21
21
  //
22
22
  // 上线护栏(生产上有别的团队的网络和节点):
23
23
  // COMMHUB_DUE_REMINDER_NETWORKS=<id,id,…> 只给这些网络发;不设 = 所有网络(自建 Hub 默认就有这个功能)。
24
+ // #524 按网络开关(一个 Hub 上多个团队的网络,只想给自己的网络开):
25
+ // COMMHUB_DUE_REMINDERS_NETWORKS=<id,id,…> 白名单。COMMHUB_DUE_REMINDERS=0 时也生效:只给这些网络发。
26
+ // COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS=<id,id,…> 黑名单。功能开着时这些网络不发(白名单里也排除)。
27
+ // 两个新变量都不设 → 行为与之前完全相同。生效范围在 startHub 时打一行日志(只有网络 id)。
28
+ // 过滤写在扫描 SQL 的 WHERE 里(基线 INSERT 与候选 SELECT 同一个条件),范围外网络的行根本不会被读出来。
24
29
  // COMMHUB_DUE_REMINDER_NODES=1 给负责 Agent 节点发消息;默认关(人照常提醒)。
25
30
  // 开通时不补发:每个网络第一次被扫描到时记一行基线(kind='baseline',sent_at = 那一刻)。只有在基线**之后**才变成逾期的卡
26
31
  // 会收到「已逾期」提醒;基线之前就已经逾期的卡永远不发逾期提醒(打开功能 / 把网络加进白名单都不会刷一批旧卡)。
@@ -72,13 +77,60 @@ export function dueReminderTimezone(): string {
72
77
  const tz = process.env.COMMHUB_DUE_REMINDER_TZ?.trim() || "Asia/Shanghai";
73
78
  try { new Intl.DateTimeFormat("en-US", { timeZone: tz }); return tz; } catch { return "Asia/Shanghai"; }
74
79
  }
75
- /** COMMHUB_DUE_REMINDER_NETWORKS:逗号分隔的网络 id;没设(或只有空白)→ null = 所有网络。 */
76
- export function reminderNetworks(): string[] | null {
77
- const raw = process.env.COMMHUB_DUE_REMINDER_NETWORKS;
80
+ /** 逗号分隔的网络 id;没设(或只有空白 / 逗号)→ null。去重,保持顺序。 */
81
+ function envIdList(name: string): string[] | null {
82
+ const raw = process.env[name];
78
83
  if (raw === undefined) return null;
79
- const ids = raw.split(",").map((x) => x.trim()).filter(Boolean);
84
+ const ids = [...new Set(raw.split(",").map((x) => x.trim()).filter(Boolean))];
80
85
  return ids.length ? ids : null;
81
86
  }
87
+ /** COMMHUB_DUE_REMINDER_NETWORKS:逗号分隔的网络 id;没设(或只有空白)→ null = 所有网络。 */
88
+ export function reminderNetworks(): string[] | null {
89
+ return envIdList("COMMHUB_DUE_REMINDER_NETWORKS");
90
+ }
91
+
92
+ /**
93
+ * #524 生效范围。
94
+ * off —— 不扫描(COMMHUB_DUE_REMINDERS=0 且没设 COMMHUB_DUE_REMINDERS_NETWORKS)。
95
+ * all —— 所有网络,减去 exclude。
96
+ * only —— 只扫 include(已减去 exclude;可能为空 = 什么都不扫)。
97
+ * 白名单 = COMMHUB_DUE_REMINDERS_NETWORKS ∪ COMMHUB_DUE_REMINDER_NETWORKS(旧名);但只有新名能在 COMMHUB_DUE_REMINDERS=0 时打开。
98
+ * 白名单一旦设了就收窄(包括 COMMHUB_DUE_REMINDERS=1):宁可少发,不给没点名的网络发。
99
+ */
100
+ export type DueReminderScope =
101
+ | { mode: "off" }
102
+ | { mode: "all"; exclude: string[] }
103
+ | { mode: "only"; include: string[]; exclude: string[] };
104
+
105
+ export function dueReminderScope(): DueReminderScope {
106
+ const allow = envIdList("COMMHUB_DUE_REMINDERS_NETWORKS");
107
+ const legacy = envIdList("COMMHUB_DUE_REMINDER_NETWORKS");
108
+ const exclude = envIdList("COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS") ?? [];
109
+ if (process.env.COMMHUB_DUE_REMINDERS === "0" && !allow) return { mode: "off" };
110
+ if (!allow && !legacy) return { mode: "all", exclude };
111
+ const include = [...new Set([...(allow ?? []), ...(legacy ?? [])])].filter((id) => !exclude.includes(id));
112
+ return { mode: "only", include, exclude };
113
+ }
114
+
115
+ export function describeDueReminderScope(scope: DueReminderScope = dueReminderScope()): string {
116
+ if (scope.mode === "off") return "off";
117
+ if (scope.mode === "all") return scope.exclude.length ? `all networks except ${scope.exclude.join(",")}` : "all networks";
118
+ return scope.include.length ? `only networks ${scope.include.join(",")}` : "no networks (allowlist empty after exclude)";
119
+ }
120
+
121
+ /**
122
+ * 范围条件(拼在 WHERE 后面)。只 push 这个分支真正用到的参数(PG 预编译拒绝绑了没用的参数)。
123
+ * null = 范围为空,调用方不该查询。
124
+ */
125
+ export function dueScopeSql(scope: DueReminderScope, params: unknown[]): string | null {
126
+ if (scope.mode === "off") return null;
127
+ if (scope.mode === "only") {
128
+ if (!scope.include.length) return null;
129
+ return ` AND network_id IN (${scope.include.map((id) => `?${params.push(id)}`).join(", ")})`;
130
+ }
131
+ if (!scope.exclude.length) return "";
132
+ return ` AND network_id NOT IN (${scope.exclude.map((id) => `?${params.push(id)}`).join(", ")})`;
133
+ }
82
134
  export function nodeRemindersEnabled(): boolean { return process.env.COMMHUB_DUE_REMINDER_NODES === "1"; }
83
135
  export const BASELINE_KIND = "baseline";
84
136
 
@@ -213,13 +265,15 @@ export function runDueReminders(opts: { now?: number } = {}): DueReminderSent[]
213
265
  // 范围:最老 = 今天 - maxOverdue - 1(UTC 日期 ≤ 本地日期,多留一天);最新 < 今天 + 2(24 小时内的时刻型 UTC 日期 ≤ 明天)。
214
266
  const lo = addDays(today, -maxOverdue - 1);
215
267
  const hi = addDays(today, 2);
216
- const allow = reminderNetworks();
268
+ const scope = dueReminderScope();
269
+ // 范围为空(关闭 / 白名单全被排除)→ 什么都不读、不写基线。
270
+ if (dueScopeSql(scope, []) === null) return [];
217
271
  const sentAt = new Date(nowMs).toISOString();
218
272
  // 基线:范围内每个网络第一次被扫到的那一刻(已有就不动)。一条 INSERT … SELECT。
219
273
  {
220
274
  const bp: unknown[] = [BASELINE_KIND, sentAt];
221
275
  // WHERE 必须有:SQLite 里「INSERT … SELECT … FROM t ON CONFLICT」会把 ON 读成 JOIN 约束(语法错),要一个 WHERE 隔开。
222
- const where = ` WHERE 1=1${allow ? ` AND network_id IN (${allow.map((id) => `?${bp.push(id)}`).join(", ")})` : ""}`;
276
+ const where = ` WHERE 1=1${dueScopeSql(scope, bp) ?? ""}`;
223
277
  db.run(
224
278
  `INSERT INTO requirement_due_reminders (requirement_id, kind, due_on, day, network_id, sent_at)
225
279
  SELECT '__baseline__:' || network_id, ?1, '', '', network_id, ?2 FROM networks${where}
@@ -233,7 +287,7 @@ export function runDueReminders(opts: { now?: number } = {}): DueReminderSent[]
233
287
  .filter((e): e is readonly [string, number] => e[1] !== null),
234
288
  );
235
289
  const params: unknown[] = [lo, hi];
236
- const netFilter = allow ? ` AND network_id IN (${allow.map((id) => `?${params.push(id)}`).join(", ")})` : "";
290
+ const netFilter = dueScopeSql(scope, params) ?? "";
237
291
  const rows = db.all<DueRow>(
238
292
  `SELECT requirement_id, network_id, seq, title, due_on, owner_json, participants_json, agent_owner_json
239
293
  FROM requirements
@@ -299,9 +353,14 @@ function deliver(row: DueRow, r: { kind: DueKind; overdueDays: number; dueDay: s
299
353
  return { requirement_id: row.requirement_id, kind: r.kind as DueKind, users, node: notifiedNode };
300
354
  }
301
355
 
302
- /** startHub 里调用。COMMHUB_DUE_REMINDERS=0 关掉;bootServer(测试)不调用它。第一次扫描延后一个间隔外的 30 秒,不和启动抢。 */
356
+ /**
357
+ * startHub 里调用。COMMHUB_DUE_REMINDERS=0 关掉(除非设了 COMMHUB_DUE_REMINDERS_NETWORKS,见 dueReminderScope);
358
+ * bootServer(测试)不调用它。第一次扫描延后一个间隔外的 30 秒,不和启动抢。启动时打一行生效范围(只有网络 id)。
359
+ */
303
360
  export function startDueReminderTimer(): ReturnType<typeof setInterval> | null {
304
- if (process.env.COMMHUB_DUE_REMINDERS === "0") return null;
361
+ const scope = dueReminderScope();
362
+ console.log(`[due-reminders] scope: ${describeDueReminderScope(scope)}`);
363
+ if (scope.mode === "off") return null;
305
364
  const ms = envNumber("COMMHUB_DUE_REMINDER_TICK_MS", 10 * 60_000, 1_000);
306
365
  const tick = () => { try { runDueReminders(); } catch (e: any) { console.error(`[due-reminders] tick failed: ${e?.message || e}`); } };
307
366
  const first = setTimeout(tick, Math.min(ms, 30_000));
@@ -0,0 +1,233 @@
1
+ // #523 —— 定时任务连续失败:告诉创建者(可选自动暂停)。
2
+ //
3
+ // 钉住:
4
+ // 1. 连续 5 次失败(派出去的任务以 failed 结束)→ 给创建者恰好 1 条通知(别的成员 0 条),第 6–10 次不再发;
5
+ // 通知里有排程名、目标节点、失败次数、最近一次的错误(截断)和怎么暂停。
6
+ // 2. 派发时就失败(目标节点不存在)同样计数。
7
+ // 3. 中间成功一次 → 重新上膛:再连续 5 次失败才发第二条。
8
+ // 4. 一直失败:24 小时内不重发;上次告警早于 24 小时 → 再发一条。
9
+ // 5. 默认不自动暂停;COMMHUB_SCHEDULE_FAILURE_AUTO_PAUSE_RUNS=3 → 第 3 次失败后排程 paused、next_run_at 清空、
10
+ // revision+1,并通知一次。
11
+ // 6. GET /runs 返回 consecutive_failures / failure_alert_threshold / last_failure_alert_at,
12
+ // 任务以 failed 结束的 run 的 error_message 取自任务的回复正文。
13
+ //
14
+ // 跑法:cd server && COMMHUB_DB=/tmp/x.db bun test src/scheduled-failures-http.test.ts
15
+ // PG:COMMHUB_TEST_PG_URL=… COMMHUB_PG_EXPERIMENTAL=1(tests/test2123-hub-postgres-ladder 里注册)
16
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
17
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
18
+ import { addNetworkMember, createNetworkTokenForNode, register } from "./auth.js";
19
+ import { db } from "./db.js";
20
+ import { dispatchScheduledOccurrence, handleScheduledTaskRequest } from "./scheduled-tasks.js";
21
+ import { SCHEDULE_FAILING_NOTICE_KIND, failureStreak } from "./scheduled-failures.js";
22
+ import { registerTools } from "./tools.js";
23
+
24
+ const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
25
+ if (!activeDbPath) throw new Error("scheduled-failures requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
26
+
27
+ const stamp = Date.now();
28
+ const ALIAS = `fail-node-${stamp}`;
29
+ const NODE_ID = `n_fail_${stamp}`;
30
+ let NET = "", creatorId = "", otherId = "";
31
+ const ENV_KEYS = ["COMMHUB_SCHEDULE_FAILURE_NOTICE_RUNS", "COMMHUB_SCHEDULE_FAILURE_RENOTICE_SEC", "COMMHUB_SCHEDULE_FAILURE_AUTO_PAUSE_RUNS"];
32
+ const SAFETY_ERROR = "执行出错: upstream model refused the request (safety system). " + "x".repeat(600);
33
+
34
+ function seedSchedule(name = "前后端分离巡检", targetNodeId = NODE_ID): any {
35
+ const scheduleId = `sched_${crypto.randomUUID()}`;
36
+ db.run(
37
+ `INSERT INTO scheduled_tasks
38
+ (schedule_id, network_id, created_by, name, target_node_id, target_alias, task_content, priority,
39
+ schedule_type, schedule_json, timezone, status, next_run_at)
40
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'scheduled task', 'normal', 'interval',
41
+ '{"type":"interval","every_seconds":120}', 'Asia/Shanghai', 'active', datetime('now', '+2 minutes'))`,
42
+ [scheduleId, NET, creatorId, name, targetNodeId, ALIAS],
43
+ );
44
+ return reload(scheduleId);
45
+ }
46
+ const reload = (scheduleId: string) => db.get<any>("SELECT * FROM scheduled_tasks WHERE schedule_id = ?1", scheduleId);
47
+
48
+ let slot = 0;
49
+ const nextSlot = () => new Date(Date.UTC(2026, 9, 4, 0, 0, 0) + (++slot) * 120_000).toISOString();
50
+ const fire = (schedule: any, now = new Date()) => dispatchScheduledOccurrence(schedule, nextSlot(), false, now);
51
+
52
+ const notices = (userId: string) => db.all<{ title: string; content: string; meta_json: string; from_session: string; severity: string }>(
53
+ "SELECT title, content, meta_json, from_session, severity FROM user_inbox WHERE user_id = ?1 AND network_id = ?2 AND kind = ?3 ORDER BY created_at, message_id",
54
+ userId, NET, SCHEDULE_FAILING_NOTICE_KIND,
55
+ );
56
+ const noticesFor = (userId: string, scheduleId: string) => notices(userId).filter((n) => JSON.parse(n.meta_json).schedule_failing.schedule_id === scheduleId);
57
+
58
+ async function nodeReply(args: Record<string, unknown>): Promise<any> {
59
+ const mcp = new McpServer({ name: "sched-fail", version: "0" }) as any;
60
+ const handlers: Record<string, (a: any) => Promise<any>> = {};
61
+ const original = mcp.tool.bind(mcp);
62
+ mcp.tool = (name: string, ...rest: any[]) => {
63
+ const handler = rest.at(-1);
64
+ if (typeof handler === "function") handlers[name] = handler;
65
+ return original(name, ...rest);
66
+ };
67
+ registerTools(mcp, undefined, NET, creatorId, ALIAS, true, null);
68
+ const out = await handlers.send_reply(args);
69
+ return JSON.parse(out.content[0].text);
70
+ }
71
+
72
+ /** 派一次,节点以 failed(或 replied)回复。 */
73
+ async function runOnce(schedule: any, outcome: "failed" | "replied" = "failed"): Promise<{ runId: string; taskId: string }> {
74
+ const r = fire(schedule);
75
+ expect(r.status).toBe("delivered");
76
+ const out = await nodeReply({ alias: "scheduler", text: outcome === "failed" ? SAFETY_ERROR : "done", in_reply_to: r.taskId, status: outcome, from_session: ALIAS });
77
+ expect(out.ok).toBe(true);
78
+ return { runId: r.runId, taskId: r.taskId! };
79
+ }
80
+
81
+ async function getRuns(scheduleId: string): Promise<any> {
82
+ const url = new URL(`http://hub.test/api/scheduled-tasks/${scheduleId}/runs`);
83
+ const res = await handleScheduledTaskRequest({
84
+ req: new Request(url, { method: "GET" }), url,
85
+ auth: { userId: creatorId, networkId: NET, username: "creator" },
86
+ isAdmin: false, isNodeToken: false, scope: { networkId: NET, networkIds: [NET] } as any,
87
+ });
88
+ expect(res!.status).toBe(200);
89
+ return res!.json();
90
+ }
91
+
92
+ beforeAll(() => {
93
+ const creator = register(`fail_creator_${stamp}`, "FailCreator123!", undefined, "seed");
94
+ expect(creator.ok).toBe(true);
95
+ NET = creator.network_id!; creatorId = creator.user!.user_id;
96
+ const other = register(`fail_other_${stamp}`, "FailOther123!", undefined, "seed");
97
+ otherId = other.user!.user_id;
98
+ expect(addNetworkMember(NET, otherId, "member", creatorId, { agentAccess: "all" }).ok).toBe(true);
99
+ expect(createNetworkTokenForNode(creatorId, NET, ALIAS).ok).toBe(true);
100
+ db.run("INSERT INTO nodes (node_id, node_name, alias, runtime, network_id) VALUES (?1, ?2, ?2, 'claude-code', ?3)", [NODE_ID, ALIAS, NET]);
101
+ db.run("INSERT INTO sessions (resume_id, alias, status, node_id, network_id) VALUES (?1, ?2, 'idle', ?3, ?4)", [`r_fail_${stamp}`, ALIAS, NODE_ID, NET]);
102
+ });
103
+ beforeEach(() => { for (const k of ENV_KEYS) delete process.env[k]; });
104
+ afterEach(() => { for (const k of ENV_KEYS) delete process.env[k]; });
105
+ afterAll(() => { db.run("DELETE FROM user_inbox WHERE network_id = ?1 AND kind = ?2", [NET, SCHEDULE_FAILING_NOTICE_KIND]); });
106
+
107
+ describe("#523 consecutive-failure alert", () => {
108
+ test("5 failed runs → exactly one notice to the creator with name/target/count/error/how to pause; runs 6–10 stay quiet", async () => {
109
+ const s = seedSchedule();
110
+ for (let i = 0; i < 4; i++) await runOnce(s);
111
+ expect(noticesFor(creatorId, s.schedule_id).length).toBe(0);
112
+ await runOnce(s);
113
+ const mine = noticesFor(creatorId, s.schedule_id);
114
+ expect(mine.length).toBe(1);
115
+ expect(noticesFor(otherId, s.schedule_id).length).toBe(0);
116
+ const n = mine[0];
117
+ expect(n.from_session).toBe(ALIAS);
118
+ expect(n.title).toBe("定时任务连续失败");
119
+ expect(n.severity).toBe("error");
120
+ for (const frag of ["前后端分离巡检", ALIAS, "连续 5 次", "safety system", "task_failed", "暂停", s.schedule_id]) expect(n.content).toContain(frag);
121
+ expect(n.content).not.toContain("x".repeat(400)); // 错误被截断
122
+ expect(JSON.parse(n.meta_json).schedule_failing).toEqual({ reason: "failing", schedule_id: s.schedule_id, consecutive_failures: 5, error_code: "task_failed" });
123
+ for (let i = 0; i < 5; i++) await runOnce(s);
124
+ expect(noticesFor(creatorId, s.schedule_id).length).toBe(1);
125
+ expect(reload(s.schedule_id).status).toBe("active"); // 默认不自动暂停
126
+ });
127
+
128
+ test("dispatch-time failures (target node gone) count too", () => {
129
+ const s = seedSchedule("孤儿排程", `n_missing_${stamp}`);
130
+ for (let i = 0; i < 5; i++) expect(fire(s).status).toBe("failed");
131
+ const mine = noticesFor(creatorId, s.schedule_id);
132
+ expect(mine.length).toBe(1);
133
+ expect(mine[0].content).toContain("target_node_not_found");
134
+ expect(mine[0].content).toContain("The bound node no longer exists");
135
+ });
136
+
137
+ test("a success in between re-arms: the next 5 failures notify again", async () => {
138
+ const s = seedSchedule();
139
+ for (let i = 0; i < 5; i++) await runOnce(s);
140
+ expect(noticesFor(creatorId, s.schedule_id).length).toBe(1);
141
+ await runOnce(s, "replied");
142
+ expect(failureStreak(s.schedule_id).consecutiveFailures).toBe(0);
143
+ for (let i = 0; i < 4; i++) await runOnce(s);
144
+ expect(noticesFor(creatorId, s.schedule_id).length).toBe(1);
145
+ await runOnce(s);
146
+ expect(noticesFor(creatorId, s.schedule_id).length).toBe(2);
147
+ });
148
+
149
+ test("still failing: no repeat within 24h; one more once the last alert is older than 24h", async () => {
150
+ const s = seedSchedule();
151
+ for (let i = 0; i < 5; i++) await runOnce(s);
152
+ expect(noticesFor(creatorId, s.schedule_id).length).toBe(1);
153
+ db.run("UPDATE scheduled_tasks SET failure_alert_at = ?1 WHERE schedule_id = ?2", [new Date(Date.now() - 23 * 3_600_000).toISOString(), s.schedule_id]);
154
+ await runOnce(s);
155
+ expect(noticesFor(creatorId, s.schedule_id).length).toBe(1);
156
+ db.run("UPDATE scheduled_tasks SET failure_alert_at = ?1 WHERE schedule_id = ?2", [new Date(Date.now() - 25 * 3_600_000).toISOString(), s.schedule_id]);
157
+ await runOnce(s);
158
+ expect(noticesFor(creatorId, s.schedule_id).length).toBe(2);
159
+ await runOnce(s);
160
+ expect(noticesFor(creatorId, s.schedule_id).length).toBe(2);
161
+ });
162
+
163
+ test("COMMHUB_SCHEDULE_FAILURE_NOTICE_RUNS changes N; no creator → no notice", async () => {
164
+ process.env.COMMHUB_SCHEDULE_FAILURE_NOTICE_RUNS = "2";
165
+ const s = seedSchedule();
166
+ await runOnce(s);
167
+ expect(noticesFor(creatorId, s.schedule_id).length).toBe(0);
168
+ await runOnce(s);
169
+ expect(noticesFor(creatorId, s.schedule_id).length).toBe(1);
170
+
171
+ const orphan = seedSchedule();
172
+ db.run("UPDATE scheduled_tasks SET created_by = NULL WHERE schedule_id = ?1", [orphan.schedule_id]);
173
+ const row = reload(orphan.schedule_id);
174
+ for (let i = 0; i < 3; i++) await runOnce(row);
175
+ expect(noticesFor(creatorId, orphan.schedule_id).length).toBe(0);
176
+ });
177
+ });
178
+
179
+ describe("#523 auto-pause (opt-in)", () => {
180
+ test("off by default: 10 failures leave the schedule active", async () => {
181
+ const s = seedSchedule();
182
+ for (let i = 0; i < 10; i++) await runOnce(s);
183
+ const after = reload(s.schedule_id);
184
+ expect(after.status).toBe("active");
185
+ expect(after.next_run_at).not.toBeNull();
186
+ });
187
+
188
+ test("COMMHUB_SCHEDULE_FAILURE_AUTO_PAUSE_RUNS=3 pauses after the 3rd failure and tells the creator once", async () => {
189
+ process.env.COMMHUB_SCHEDULE_FAILURE_AUTO_PAUSE_RUNS = "3";
190
+ const s = seedSchedule();
191
+ await runOnce(s); await runOnce(s);
192
+ expect(reload(s.schedule_id).status).toBe("active");
193
+ await runOnce(s);
194
+ const after = reload(s.schedule_id);
195
+ expect(after.status).toBe("paused");
196
+ expect(after.next_run_at).toBeNull();
197
+ expect(after.revision).toBe(s.revision + 1);
198
+ const mine = noticesFor(creatorId, s.schedule_id);
199
+ expect(mine.length).toBe(1);
200
+ expect(mine[0].title).toBe("定时任务连续失败,已自动暂停");
201
+ expect(JSON.parse(mine[0].meta_json).schedule_failing.reason).toBe("paused");
202
+ // 手动 run-now 再失败两次到 N=5:同一段失败,已经通知过 → 不再发
203
+ await runOnce(after); await runOnce(after);
204
+ expect(noticesFor(creatorId, s.schedule_id).length).toBe(1);
205
+ });
206
+ });
207
+
208
+ describe("#523 GET /runs", () => {
209
+ test("exposes consecutive_failures and the failed task's reason as error_message", async () => {
210
+ const s = seedSchedule();
211
+ await runOnce(s, "replied");
212
+ const fails = [await runOnce(s), await runOnce(s)];
213
+ const body = await getRuns(s.schedule_id);
214
+ expect(body.ok).toBe(true);
215
+ expect(body.consecutive_failures).toBe(2);
216
+ expect(body.failure_alert_threshold).toBe(5);
217
+ expect(body.last_failure_alert_at).toBeNull();
218
+ expect(body.runs.length).toBe(3);
219
+ const failed = body.runs.find((r: any) => r.run_id === fails[1].runId);
220
+ expect(failed.status).toBe("failed");
221
+ expect(failed.error_code).toBe("task_failed");
222
+ expect(failed.error_message).toContain("safety system");
223
+ expect(failed.error_message.length).toBeLessThanOrEqual(500);
224
+ for (const k of ["scheduled_for", "created_at", "completed_at", "task_id"]) expect(k in failed).toBe(true);
225
+ const ok = body.runs.find((r: any) => r.status === "replied");
226
+ expect(ok.error_message).toBeNull();
227
+
228
+ for (let i = 0; i < 3; i++) await runOnce(s);
229
+ const again = await getRuns(s.schedule_id);
230
+ expect(again.consecutive_failures).toBe(5);
231
+ expect(typeof again.last_failure_alert_at).toBe("string");
232
+ });
233
+ });
@@ -0,0 +1,179 @@
1
+ // #523 —— 定时任务连续失败:告诉创建者(可选自动暂停)。
2
+ //
3
+ // 现场:一个每 2 分钟一次的排程,每一次派出去的任务都被节点以 failed 结束(上游模型拒绝),一天 720 次,
4
+ // 排程照样 active,创建者什么都不知道。#464 只管「被上一次卡住」(skipped),不管「每次都失败」。
5
+ //
6
+ // 1. 计数:consecutive_failures = 最近一次成功(run.status='replied')之后,status='failed' 的 run 数。
7
+ // skipped / cancelled / expired / 还没结束的 run 不算失败,也不打断连续(expired 由 #464 的超时告警负责)。
8
+ // 失败有两种来源,都会走到这里:派发时就失败(节点不在 / 被停用 / 降级 / 创建者无权限,
9
+ // scheduled-tasks.ts 提交后调用),以及派出去的任务以 failed 结束(db.ts 的 run 镜像触发钩子)。
10
+ // 2. 告警:连续失败 ≥ N(默认 5)→ 给排程创建者发**一条**通知(agent-notice.ts,与 #462/#464 同一条路,
11
+ // 出现在与目标节点的会话里)。去重落在 scheduled_tasks 两列上(条件写,一次只有一个调用方抢得到):
12
+ // failure_alert_key = 告警那一刻「最近一次成功」的 run_id —— 之后又成功过,key 就变了,下一段失败重新上膛;
13
+ // failure_alert_at = 告警时间 —— 一直没成功过,24 小时后再提醒一次。Hub 重启不会重发。
14
+ // 3. 自动暂停:默认关。COMMHUB_SCHEDULE_FAILURE_AUTO_PAUSE_RUNS=M(≥1)时,连续失败 ≥ M 的 active 排程
15
+ // 被置为 paused(next_run_at 清空,revision+1,与用户手动暂停同形),并通知创建者。
16
+ //
17
+ // 环境变量:COMMHUB_SCHEDULE_FAILURE_NOTICE_RUNS(N,默认 5)、COMMHUB_SCHEDULE_FAILURE_RENOTICE_SEC
18
+ // (默认 86400)、COMMHUB_SCHEDULE_FAILURE_AUTO_PAUSE_RUNS(M,默认 0 = 关)。
19
+
20
+ import { db, setScheduledRunTerminalHook } from "./db.js";
21
+ import { sendAgentNotice } from "./agent-notice.js";
22
+
23
+ export const SCHEDULE_FAILING_NOTICE_KIND = "schedule_failing";
24
+ const ERROR_PREVIEW_CHARS = 300;
25
+
26
+ function envInt(name: string, fallback: number, min: number): number {
27
+ const raw = process.env[name];
28
+ if (raw === undefined || raw.trim() === "") return fallback;
29
+ const n = Number(raw);
30
+ return Number.isFinite(n) && n >= min ? Math.floor(n) : fallback;
31
+ }
32
+
33
+ export function failureNoticeRuns(): number { return envInt("COMMHUB_SCHEDULE_FAILURE_NOTICE_RUNS", 5, 1); }
34
+ export function failureRenoticeMs(): number { return envInt("COMMHUB_SCHEDULE_FAILURE_RENOTICE_SEC", 86_400, 1) * 1000; }
35
+ /** 0 = 不自动暂停(默认)。 */
36
+ export function failureAutoPauseRuns(): number { return envInt("COMMHUB_SCHEDULE_FAILURE_AUTO_PAUSE_RUNS", 0, 0); }
37
+
38
+ export type FailureStreak = { consecutiveFailures: number; lastSuccessRunId: string | null };
39
+
40
+ /** 最近一次成功之后的失败 run 数。scheduled_for 每个排程唯一(UNIQUE),按它排序是确定的。 */
41
+ export function failureStreak(scheduleId: string): FailureStreak {
42
+ const lastSuccess = db.get<{ run_id: string; scheduled_for: string }>(
43
+ "SELECT run_id, scheduled_for FROM scheduled_task_runs WHERE schedule_id = ?1 AND status = 'replied' ORDER BY scheduled_for DESC LIMIT 1",
44
+ scheduleId,
45
+ );
46
+ const n = db.get<{ n: number }>(
47
+ "SELECT COUNT(*) AS n FROM scheduled_task_runs WHERE schedule_id = ?1 AND status = 'failed' AND scheduled_for > ?2",
48
+ scheduleId, lastSuccess?.scheduled_for ?? "",
49
+ )?.n ?? 0;
50
+ return { consecutiveFailures: Number(n), lastSuccessRunId: lastSuccess?.run_id ?? null };
51
+ }
52
+
53
+ type ScheduleForAlert = {
54
+ schedule_id: string;
55
+ network_id: string;
56
+ created_by: string | null;
57
+ name: string;
58
+ target_alias: string;
59
+ status: string;
60
+ };
61
+
62
+ export type FailureNotice = {
63
+ reason: "failing" | "paused";
64
+ scheduleId: string;
65
+ scheduleName: string;
66
+ networkId: string;
67
+ createdBy: string;
68
+ alias: string;
69
+ failures: number;
70
+ errorCode: string | null;
71
+ errorMessage: string | null;
72
+ };
73
+
74
+ function latestFailure(scheduleId: string): { error_code: string | null; error_message: string | null } {
75
+ const row = db.get<{ error_code: string | null; error_message: string | null; result: string | null }>(
76
+ `SELECT r.error_code, r.error_message, t.result
77
+ FROM scheduled_task_runs r
78
+ LEFT JOIN tasks t ON t.task_id = r.task_id AND t.network_id = r.network_id
79
+ WHERE r.schedule_id = ?1 AND r.status = 'failed'
80
+ ORDER BY r.scheduled_for DESC LIMIT 1`,
81
+ scheduleId,
82
+ );
83
+ // 任务以 failed 结束时 run.error_message 为空,原因在任务的回复正文里。
84
+ return { error_code: row?.error_code ?? null, error_message: row?.error_message || row?.result || null };
85
+ }
86
+
87
+ function truncate(s: string, max: number): string {
88
+ const flat = s.replace(/\s+/g, " ").trim();
89
+ return flat.length > max ? `${flat.slice(0, max)}…` : flat;
90
+ }
91
+
92
+ export function failureNoticeText(n: FailureNotice): { title: string; text: string } {
93
+ const err = n.errorMessage ? truncate(n.errorMessage, ERROR_PREVIEW_CHARS) : "(没有错误详情)";
94
+ const code = n.errorCode ? ` [${n.errorCode}]` : "";
95
+ const head = `定时任务「${n.scheduleName}」(派给节点 ${n.alias})已经连续 ${n.failures} 次执行失败。\n\n最近一次的错误${code}:${err}\n\n`;
96
+ if (n.reason === "paused") {
97
+ return {
98
+ title: "定时任务连续失败,已自动暂停",
99
+ text: `${head}按 Hub 的设置,它已被自动暂停,不会再派发。处理好原因后,在定时任务里把它恢复为「启用」即可。`,
100
+ };
101
+ }
102
+ return {
103
+ title: "定时任务连续失败",
104
+ text: `${head}它仍在按计划运行。如需停止,在定时任务里暂停它(或 PATCH /api/scheduled-tasks/${n.scheduleId},body 带 {"status":"paused","revision":<当前 revision>})。`
105
+ + `在它重新成功之前,24 小时内不会再提醒。`,
106
+ };
107
+ }
108
+
109
+ export function sendFailureNotice(n: FailureNotice): string | null {
110
+ try {
111
+ const { title, text } = failureNoticeText(n);
112
+ return sendAgentNotice({
113
+ networkId: n.networkId, userId: n.createdBy, fromAlias: n.alias, kind: SCHEDULE_FAILING_NOTICE_KIND, title, text, severity: "error",
114
+ meta: { schedule_failing: { reason: n.reason, schedule_id: n.scheduleId, consecutive_failures: n.failures, error_code: n.errorCode } },
115
+ idPrefix: "dm_sched_",
116
+ });
117
+ } catch (e: any) {
118
+ console.error(`[scheduled-tasks] failure notice failed schedule=${n.scheduleId}: ${e?.message || e}`);
119
+ return null;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * 一个 run 以 failed 结束之后调用(派发时失败 / 任务 failed 都算)。返回发出的通知(测试用),没有则 null。
125
+ * 告警与暂停都是条件写:并发的两个调用方只有一个写得进去,所以每段失败恰好一条通知。
126
+ */
127
+ export function evaluateScheduleFailures(scheduleId: string, now = new Date()): FailureNotice | null {
128
+ const row = db.get<ScheduleForAlert>(
129
+ "SELECT schedule_id, network_id, created_by, name, target_alias, status FROM scheduled_tasks WHERE schedule_id = ?1",
130
+ scheduleId,
131
+ );
132
+ if (!row) return null;
133
+ const streak = failureStreak(scheduleId);
134
+ const noticeAt = failureNoticeRuns();
135
+ const pauseAt = failureAutoPauseRuns();
136
+ const wantsPause = pauseAt > 0 && streak.consecutiveFailures >= pauseAt && row.status === "active";
137
+ if (streak.consecutiveFailures < noticeAt && !wantsPause) return null;
138
+
139
+ let paused = false;
140
+ if (wantsPause) {
141
+ paused = db.run(
142
+ "UPDATE scheduled_tasks SET status = 'paused', next_run_at = NULL, revision = revision + 1, updated_at = datetime('now') WHERE schedule_id = ?1 AND status = 'active'",
143
+ [scheduleId],
144
+ ).changes === 1;
145
+ }
146
+ const key = streak.lastSuccessRunId ?? "";
147
+ const cutoff = new Date(now.getTime() - failureRenoticeMs()).toISOString();
148
+ // 去重:同一段失败(key 没变)且上次告警不到 24 小时 → 不写、不发。暂停是另一件事,照发。
149
+ const claimed = db.run(
150
+ `UPDATE scheduled_tasks SET failure_alert_at = ?1, failure_alert_key = ?2
151
+ WHERE schedule_id = ?3
152
+ AND (failure_alert_at IS NULL OR COALESCE(failure_alert_key, '') <> ?2 OR failure_alert_at <= ?4)`,
153
+ [now.toISOString(), key, scheduleId, cutoff],
154
+ ).changes === 1;
155
+ if (paused && !claimed) {
156
+ db.run("UPDATE scheduled_tasks SET failure_alert_at = ?1, failure_alert_key = ?2 WHERE schedule_id = ?3", [now.toISOString(), key, scheduleId]);
157
+ }
158
+ if (!paused && !claimed) return null;
159
+ if (!row.created_by) return null;
160
+ const latest = latestFailure(scheduleId);
161
+ const notice: FailureNotice = {
162
+ reason: paused ? "paused" : "failing",
163
+ scheduleId, scheduleName: row.name, networkId: row.network_id, createdBy: row.created_by, alias: row.target_alias,
164
+ failures: streak.consecutiveFailures, errorCode: latest.error_code, errorMessage: latest.error_message,
165
+ };
166
+ sendFailureNotice(notice);
167
+ return notice;
168
+ }
169
+
170
+ /** GET /runs 的附加字段。 */
171
+ export function failureSummary(scheduleId: string): { consecutive_failures: number; failure_alert_threshold: number; last_failure_alert_at: string | null } {
172
+ const streak = failureStreak(scheduleId);
173
+ const alertAt = db.get<{ failure_alert_at: string | null }>("SELECT failure_alert_at FROM scheduled_tasks WHERE schedule_id = ?1", scheduleId)?.failure_alert_at ?? null;
174
+ return { consecutive_failures: streak.consecutiveFailures, failure_alert_threshold: failureNoticeRuns(), last_failure_alert_at: alertAt };
175
+ }
176
+
177
+ setScheduledRunTerminalHook((scheduleId, status) => {
178
+ if (status === "failed") evaluateScheduleFailures(scheduleId);
179
+ });
@@ -7,6 +7,7 @@ import { hasSubscribers, pushEvent, pushNetworkObserverEvent } from "./push.js";
7
7
  import { SCHEDULED_TASK_STORAGE_SELECT } from "./rest-projections.js";
8
8
  import { parseDbTimestampMs } from "./db-timestamp.js";
9
9
  import { blockerTimedOut, expireStuckBlocker, sendStuckNotice, skipsBlockedBy, stuckNoticeSkips, stuckTimeoutMs, type StuckNotice } from "./scheduled-stuck.js";
10
+ import { evaluateScheduleFailures, failureSummary } from "./scheduled-failures.js";
10
11
 
11
12
  export type ScheduleSpec =
12
13
  | { type: "once"; run_at: string }
@@ -379,6 +380,8 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
379
380
  try {
380
381
  for (const t of timedOut) logTaskEvent(t.taskId, t.fromStatus, "expired", "hub-scheduler", `stuck timeout; schedule=${row.schedule_id} run=${runId}`);
381
382
  for (const n of notices) sendStuckNotice(n);
383
+ // #523 — a run that failed at dispatch counts toward the consecutive-failure alert (task failures arrive via db.ts).
384
+ if (finalStatus === "failed") evaluateScheduleFailures(row.schedule_id, advanceAfter);
382
385
  if (createdTaskId) logTaskEvent(createdTaskId, null, "delivered", "hub-scheduler", `schedule=${row.schedule_id} run=${runId}`);
383
386
  if (event) {
384
387
  const pending = db.get<{ cnt: number }>("SELECT COUNT(*) AS cnt FROM inbox WHERE session_name = ?1 AND network_id = ?2 AND acked = 0", event.alias, event.networkId);
@@ -548,10 +551,16 @@ export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext):
548
551
  if (sub === "runs" && req.method === "GET") {
549
552
  const limit = Math.max(1, Math.min(200, Number(url.searchParams.get("limit")) || 50));
550
553
  const runs = db.all(
551
- "SELECT run_id, schedule_id, scheduled_for, task_id, status, error_code, error_message, blocked_by_task_id, blocked_by_state, created_at, completed_at FROM scheduled_task_runs WHERE schedule_id = ?1 AND network_id = ?2 ORDER BY created_at DESC LIMIT ?3",
554
+ // #523 — a task that ended `failed` leaves run.error_message empty; its reason is the task's reply text.
555
+ `SELECT r.run_id, r.schedule_id, r.scheduled_for, r.task_id, r.status, r.error_code,
556
+ CASE WHEN r.error_message IS NULL AND r.status = 'failed' THEN SUBSTR(t.result, 1, 500) ELSE r.error_message END AS error_message,
557
+ r.blocked_by_task_id, r.blocked_by_state, r.created_at, r.completed_at
558
+ FROM scheduled_task_runs r
559
+ LEFT JOIN tasks t ON t.task_id = r.task_id AND t.network_id = r.network_id
560
+ WHERE r.schedule_id = ?1 AND r.network_id = ?2 ORDER BY r.created_at DESC, r.scheduled_for DESC LIMIT ?3`,
552
561
  row.schedule_id, row.network_id, limit,
553
562
  );
554
- return Response.json({ ok: true, runs });
563
+ return Response.json({ ok: true, runs, ...failureSummary(row.schedule_id) });
555
564
  }
556
565
  if (!sub && req.method === "GET") return Response.json({ ok: true, schedule: decodeRow(row) });
557
566
  if (!writeAllowed(ctx, row.network_id)) return jsonError("permission_denied", 403);