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

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.
@@ -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);
package/src/server.ts CHANGED
@@ -83,6 +83,7 @@ import { SIDE_THREAD_FEATURE_FLAG, SideThreadCoordinator, SideThreadPortRegistry
83
83
  import { handleSideThreadHttpRequest } from "./side-thread-http.js";
84
84
  import { createProductionSideThreadTransport } from "./side-thread-production.js";
85
85
  import { parseHubTimestamp } from "./hub-timestamp";
86
+ import { logTaskRejection } from "./task-rejection-log.js";
86
87
 
87
88
  const PORT = resolvePort(process.env.PORT);
88
89
  const HOST = process.env.HOST || "127.0.0.1";
@@ -3411,15 +3412,18 @@ return Bun.serve({
3411
3412
 
3412
3413
  // ── REST: send task ──
3413
3414
  if (url.pathname === "/api/task" && req.method === "POST") {
3415
+ // #552 — every non-2xx below goes through reject() so it leaves one log line
3416
+ // (status, error, user, requested alias/network/client_request_id; never token/text).
3414
3417
  let raw: unknown;
3418
+ const reject = (res: Response) => logTaskRejection(res, restAuth?.username, raw);
3415
3419
  try {
3416
3420
  raw = await req.json();
3417
3421
  } catch {
3418
- return withCors(req, Response.json({ error: "invalid JSON" }, { status: 400 }));
3422
+ return reject(withCors(req, Response.json({ error: "invalid JSON" }, { status: 400 })));
3419
3423
  }
3420
3424
  const parsed = TaskSchema.safeParse(raw);
3421
3425
  if (!parsed.success) {
3422
- return withCors(req, Response.json({ error: "invalid input", details: parsed.error.format() }, { status: 400 }));
3426
+ return reject(withCors(req, Response.json({ error: "invalid input", details: parsed.error.format() }, { status: 400 })));
3423
3427
  }
3424
3428
  const body = parsed.data;
3425
3429
  let taskNetId: string | null = null;
@@ -3427,7 +3431,7 @@ return Bun.serve({
3427
3431
  taskNetId = restAuth.networkId;
3428
3432
  } else if (body.network_id) {
3429
3433
  if (restAuth && !isAdmin && !getUserNetworkRole(restAuth.userId, body.network_id)) {
3430
- return withCors(req, Response.json({ ok: false, error: "access denied to requested network" }, { status: 403 }));
3434
+ return reject(withCors(req, Response.json({ ok: false, error: "access denied to requested network" }, { status: 403 })));
3431
3435
  }
3432
3436
  taskNetId = body.network_id;
3433
3437
  } else {
@@ -3436,34 +3440,34 @@ return Bun.serve({
3436
3440
  : null;
3437
3441
  }
3438
3442
  if (restAuth && !taskNetId) {
3439
- return withCors(req, Response.json({
3443
+ return reject(withCors(req, Response.json({
3440
3444
  ok: false,
3441
3445
  error: "network_id_required",
3442
3446
  message: "network_id is required when the user token has zero or multiple network memberships",
3443
- }, { status: 400 }));
3447
+ }, { status: 400 })));
3444
3448
  }
3445
3449
  // 多用户 Agent 权限:受限成员只能给授权且 can_message 的 Agent 发;
3446
3450
  // 目标不存在 / 没授权 / 只读授权 → 同一个 403,不留 alias 探测差异。发件人固定为自己的用户名。
3447
3451
  const restrictedSender = !!restAuth && !isAdmin && !restAuth.networkId && isAgentRestricted(restAuth.userId, taskNetId!);
3448
3452
  if (restrictedSender) {
3449
3453
  if (!canRestWriteNetworkAsHuman(restAuth, taskNetId, isAdmin)) {
3450
- return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
3454
+ return reject(withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 })));
3451
3455
  }
3452
3456
  const restrictedTarget = resolveCanonicalAlias(taskNetId, body.alias).alias;
3453
3457
  const restrictedSession = db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", restrictedTarget, taskNetId);
3454
3458
  if (!restrictedSession || !canMessageAgent(restAuth!.userId, taskNetId, { alias: restrictedTarget, nodeId: restrictedSession.node_id })) {
3455
- return withCors(req, Response.json({ ok: false, error: "agent_not_granted", message: "this agent has not been granted to you; ask a network admin" }, { status: 403 }));
3459
+ return reject(withCors(req, Response.json({ ok: false, error: "agent_not_granted", message: "this agent has not been granted to you; ask a network admin" }, { status: 403 })));
3456
3460
  }
3457
3461
  if (typeof body.from === "string" && body.from.trim() && body.from.trim() !== restAuth!.username) {
3458
- return withCors(req, Response.json({ ok: false, error: "from_session_identity_mismatch", message: "restricted members always send as their own username" }, { status: 403 }));
3462
+ return reject(withCors(req, Response.json({ ok: false, error: "from_session_identity_mismatch", message: "restricted members always send as their own username" }, { status: 403 })));
3459
3463
  }
3460
3464
  for (const raw of [(body as any).attachments, (body as any).meta?.attachments]) {
3461
3465
  if (restrictedMemberAttachmentsDenied(restAuth!.userId, restAuth!.username, taskNetId!, raw)) {
3462
- return withCors(req, Response.json({ ok: false, error: "attachment_not_accessible" }, { status: 403 }));
3466
+ return reject(withCors(req, Response.json({ ok: false, error: "attachment_not_accessible" }, { status: 403 })));
3463
3467
  }
3464
3468
  }
3465
3469
  } else if (!canRestWriteNetwork(restAuth, taskNetId, isAdmin)) {
3466
- return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
3470
+ return reject(withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 })));
3467
3471
  }
3468
3472
  const canonical = resolveCanonicalAlias(taskNetId, body.alias);
3469
3473
  const targetAlias = canonical.alias;
@@ -3471,17 +3475,17 @@ return Bun.serve({
3471
3475
  alias: targetAlias,
3472
3476
  nodeId: db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", targetAlias, taskNetId)?.node_id ?? null,
3473
3477
  }));
3474
- if (nodeDenied) return nodeDenied;
3478
+ if (nodeDenied) return reject(nodeDenied);
3475
3479
  const target = resolveRestDeliveryTarget(targetAlias, taskNetId);
3476
3480
  if (target.state === "not_found") {
3477
- return withCors(req, Response.json({
3481
+ return reject(withCors(req, Response.json({
3478
3482
  ok: false,
3479
3483
  error: "alias_not_found",
3480
3484
  message: target.message,
3481
3485
  alias: targetAlias,
3482
3486
  queued: false,
3483
3487
  ...(canonical.renamed ? { renamed_from: body.alias, renamed_to: targetAlias } : {}),
3484
- }, { status: 404 }));
3488
+ }, { status: 404 })));
3485
3489
  }
3486
3490
  // Identity binding, mirroring the MCP transport (tools.ts defaultFrom /
3487
3491
  // fromIdentityMismatchReply). Both transports resolve the same
@@ -3507,13 +3511,13 @@ return Bun.serve({
3507
3511
  requestedFrom: body.from,
3508
3512
  });
3509
3513
  if (!identity.ok) {
3510
- return withCors(req, Response.json({
3514
+ return reject(withCors(req, Response.json({
3511
3515
  ok: false,
3512
3516
  error: identity.error,
3513
3517
  message: identity.message,
3514
3518
  token_alias: identity.tokenAlias,
3515
3519
  requested_from_session: identity.requestedFromSession,
3516
- }, { status: 403 }));
3520
+ }, { status: 403 })));
3517
3521
  }
3518
3522
  const fromSession = identity.fromSession;
3519
3523
  const ttlSeconds = (body as any).ttl_seconds || 3600;
@@ -3528,7 +3532,7 @@ return Bun.serve({
3528
3532
  // size cap, etc.).
3529
3533
  const attachmentsResult = validateAttachments((body as any).attachments ?? (body as any).meta?.attachments);
3530
3534
  if (!attachmentsResult.ok) {
3531
- return withCors(req, Response.json({ ok: false, error: "bad_attachments", message: attachmentsResult.error }, { status: 400 }));
3535
+ return reject(withCors(req, Response.json({ ok: false, error: "bad_attachments", message: attachmentsResult.error }, { status: 400 })));
3532
3536
  }
3533
3537
  const mergedMeta = attachmentsResult.attachments.length
3534
3538
  ? { ...((body as any).meta && typeof (body as any).meta === "object" ? (body as any).meta : {}), attachments: attachmentsResult.attachments }
@@ -3616,14 +3620,14 @@ return Bun.serve({
3616
3620
  {
3617
3621
  const lc = assertNodeActive(targetAlias, taskNetId ?? null);
3618
3622
  if (!lc.ok) {
3619
- return withCors(req, Response.json(lc, { status: 409 }));
3623
+ return reject(withCors(req, Response.json(lc, { status: 409 })));
3620
3624
  }
3621
3625
  }
3622
3626
  // #460 — refuse dispatch to a node whose fresh health says a layer is down (409, same as the
3623
3627
  // lifecycle guard above). Unknown / stale health = allowed: old nodes keep working.
3624
3628
  {
3625
3629
  const hg = assertNodeHealthy(targetAlias, taskNetId ?? null, { force: body.force === true, forceAllowed: !restAuth?.networkId });
3626
- if (!hg.ok) return withCors(req, Response.json(hg, { status: 409 }));
3630
+ if (!hg.ok) return reject(withCors(req, Response.json(hg, { status: 409 })));
3627
3631
  }
3628
3632
  // #500 step 2 — what is already queued on the target, read BEFORE this task is written (advice only, never blocks).
3629
3633
  const queueInfo = dispatchQueueInfo(targetAlias, taskNetId, target.session?.status, ttlSeconds);