@sleep2agi/commhub-server 0.9.0-preview.87 → 0.9.0-preview.89
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 +1 -1
- package/src/agent-notice.ts +48 -0
- package/src/db.ts +2 -2
- package/src/http-gzip.ts +3 -1
- package/src/model-auth-notify.ts +7 -23
- package/src/node-health-store.ts +16 -1
- package/src/requirements-errors-mcp-http.test.ts +163 -0
- package/src/requirements-errors.ts +129 -0
- package/src/requirements-list-mcp-slim-http.test.ts +155 -0
- package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
- package/src/requirements-search.ts +9 -2
- package/src/requirements.ts +35 -25
- package/src/scheduled-stuck-http.test.ts +238 -0
- package/src/scheduled-stuck.ts +153 -0
- package/src/scheduled-tasks.ts +32 -2
- package/src/server.ts +19 -3
- package/src/status-read-cache-http.test.ts +229 -0
- package/src/status-read-cache.ts +108 -0
- package/src/tools.ts +39 -19
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// #470(MCP 任务生命周期测试报告问题 1)—— Agent 经 MCP 把负责人设成节点:以前走旧 App 的兼容改写,
|
|
2
|
+
// 人类负责人被静默清空、负责 Agent 被换掉,响应还显示 owner=节点。现在:
|
|
3
|
+
// - MCP 上 owner 传节点(建 / 改 / upsert,节点令牌和用户令牌都一样)→ 400 owner_must_be_human + hint,什么都不写;
|
|
4
|
+
// - MCP 写入的响应就是落库的那一行(和紧接着的 requirements_get 一字不差);
|
|
5
|
+
// - REST 上旧 App ≤ 0.2.142 的兼容路径不变(requirements-http.test.ts「old-app compat」那条钉着)。
|
|
6
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
7
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
import { tmpdir } from "os";
|
|
10
|
+
import { register, createNetworkTokenForNode } from "./auth.js";
|
|
11
|
+
import { db } from "./db.js";
|
|
12
|
+
|
|
13
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-owner-strict-mcp-"));
|
|
14
|
+
let BASE = "";
|
|
15
|
+
let hub: any = null;
|
|
16
|
+
const PW = "OwnerStrictMcpPassw0rd!x";
|
|
17
|
+
let NET = "", ownerToken = "", ownerId = "", nodeToken = "";
|
|
18
|
+
const human = () => ({ kind: "user", id: ownerId });
|
|
19
|
+
const nodeA = { kind: "node", id: "node_strict_a" };
|
|
20
|
+
const nodeB = { kind: "node", id: "node_strict_b" };
|
|
21
|
+
|
|
22
|
+
async function rest(token: string, method: string, path: string, body?: unknown) {
|
|
23
|
+
const res = await fetch(`${BASE}${path}`, { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
|
|
24
|
+
const text = await res.text();
|
|
25
|
+
let json: any = null;
|
|
26
|
+
try { json = JSON.parse(text); } catch {}
|
|
27
|
+
return { status: res.status, body: json };
|
|
28
|
+
}
|
|
29
|
+
async function mcp(token: string, name: string, args: Record<string, unknown>): Promise<any> {
|
|
30
|
+
const res = await fetch(`${BASE}/mcp`, {
|
|
31
|
+
method: "POST",
|
|
32
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
|
|
33
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
|
|
34
|
+
});
|
|
35
|
+
const raw = await res.text();
|
|
36
|
+
const lines = raw.split("\n").filter(x => x.startsWith("data:"));
|
|
37
|
+
const out = lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(raw);
|
|
38
|
+
return JSON.parse(out.result.content[0].text);
|
|
39
|
+
}
|
|
40
|
+
const stored = (id: string) => db.get<{ owner_json: string | null; agent_owner_json: string | null }>("SELECT owner_json, agent_owner_json FROM requirements WHERE requirement_id = ?1", id)!;
|
|
41
|
+
|
|
42
|
+
beforeAll(async () => {
|
|
43
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
44
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
45
|
+
process.env.HOST = "127.0.0.1";
|
|
46
|
+
const a = register(`strict_owner_${Date.now()}`, PW, undefined, "Owner");
|
|
47
|
+
ownerToken = a.token!; ownerId = a.user!.user_id; NET = a.network_id!;
|
|
48
|
+
nodeToken = createNetworkTokenForNode(ownerId, NET, "strict-a", "node_strict_a").token!;
|
|
49
|
+
createNetworkTokenForNode(ownerId, NET, "strict-b", "node_strict_b");
|
|
50
|
+
const mod: any = await import("./server.js");
|
|
51
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
52
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
53
|
+
}, 30_000);
|
|
54
|
+
|
|
55
|
+
afterAll(() => {
|
|
56
|
+
try { hub?.stop?.(true); } catch {}
|
|
57
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("MCP: owner must be a person", () => {
|
|
61
|
+
test("update owner=node → 400 owner_must_be_human + hint; the human owner and the agent owner are untouched", async () => {
|
|
62
|
+
const card = (await mcp(nodeToken, "requirements_create", { name: "负责人保护", owner: human(), agent_owner: nodeB })).requirement;
|
|
63
|
+
expect(card.owner).toEqual(human());
|
|
64
|
+
const r = await mcp(nodeToken, "requirements_update", { id: card.id, owner: nodeA });
|
|
65
|
+
expect(r.ok).toBe(false);
|
|
66
|
+
expect(r.error).toBe("owner_must_be_human");
|
|
67
|
+
expect(r.status).toBe(400);
|
|
68
|
+
expect(r.hint).toContain("agent_owner");
|
|
69
|
+
expect(r.owner_coerced_to_agent_owner).toBeUndefined();
|
|
70
|
+
const row = stored(card.id);
|
|
71
|
+
expect(JSON.parse(row.owner_json!)).toEqual(human());
|
|
72
|
+
expect(JSON.parse(row.agent_owner_json!)).toEqual(nodeB);
|
|
73
|
+
const got = (await mcp(nodeToken, "requirements_get", { id: card.id })).requirement;
|
|
74
|
+
expect(got.owner).toEqual(human());
|
|
75
|
+
expect(got.agent_owner).toEqual(nodeB);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("…the same for a user token over MCP, for create (nothing is written) and for upsert", async () => {
|
|
79
|
+
const r = await mcp(ownerToken, "requirements_update", { network_id: NET, id: (await mcp(ownerToken, "requirements_create", { network_id: NET, name: "用户令牌", owner: human() })).requirement.id, owner: nodeA });
|
|
80
|
+
expect(r.error).toBe("owner_must_be_human");
|
|
81
|
+
const before = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM requirements WHERE network_id = ?1", NET)!.n;
|
|
82
|
+
const c = await mcp(nodeToken, "requirements_create", { name: "建时就传节点", owner: nodeA });
|
|
83
|
+
expect(c.error).toBe("owner_must_be_human");
|
|
84
|
+
expect(c.hint).toContain("agent_owner");
|
|
85
|
+
expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM requirements WHERE network_id = ?1", NET)!.n).toBe(before);
|
|
86
|
+
const u = await mcp(nodeToken, "requirements_upsert_by_external_ref", { external_ref: "github:example/repo#470", name: "upsert", owner: nodeA });
|
|
87
|
+
expect(u.error).toBe("owner_must_be_human");
|
|
88
|
+
expect(db.get("SELECT 1 FROM requirements WHERE external_ref = 'github:example/repo#470'")).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("the right way still works: agent_owner = node, owner = person, owner = null", async () => {
|
|
92
|
+
const card = (await mcp(nodeToken, "requirements_create", { name: "正确的写法", owner: human() })).requirement;
|
|
93
|
+
const r = await mcp(nodeToken, "requirements_update", { id: card.id, agent_owner: nodeA });
|
|
94
|
+
expect(r.ok).toBe(true);
|
|
95
|
+
expect(r.requirement.owner).toEqual(human());
|
|
96
|
+
expect(r.requirement.agent_owner).toEqual(nodeA);
|
|
97
|
+
const cleared = await mcp(nodeToken, "requirements_update", { id: card.id, owner: null });
|
|
98
|
+
expect(cleared.requirement.owner).toBeNull();
|
|
99
|
+
// agent_owner given a person is refused the other way round, with its own hint.
|
|
100
|
+
const wrong = await mcp(nodeToken, "requirements_update", { id: card.id, agent_owner: human() });
|
|
101
|
+
expect(wrong.error).toBe("agent_owner_must_be_agent");
|
|
102
|
+
expect(wrong.hint).toContain("owner");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("every MCP write's response equals a fresh requirements_get", async () => {
|
|
106
|
+
const card = (await mcp(nodeToken, "requirements_create", { name: "响应即落库", owner: human(), checklist: [{ text: "一" }] })).requirement;
|
|
107
|
+
expect((await mcp(nodeToken, "requirements_get", { id: card.id })).requirement).toEqual(card);
|
|
108
|
+
const patches: Record<string, unknown>[] = [
|
|
109
|
+
{ column: "doing" }, { agent_owner: nodeA }, { participants: [nodeB, human()] }, { owner: null }, { owner: human(), priority: "high" },
|
|
110
|
+
{ agent_owner: null }, { due: "2026-10-10" }, { archived: true }, { archived: false },
|
|
111
|
+
];
|
|
112
|
+
for (const p of patches) {
|
|
113
|
+
const r = await mcp(nodeToken, "requirements_update", { id: card.id, ...p });
|
|
114
|
+
expect(r.ok).toBe(true);
|
|
115
|
+
expect(r.owner_coerced_to_agent_owner).toBeUndefined();
|
|
116
|
+
expect(r.requirement).toEqual((await mcp(nodeToken, "requirements_get", { id: card.id })).requirement);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("REST", () => {
|
|
122
|
+
test("new clients' responses equal a fresh GET (the old-app compat echo is the only exception, pinned in requirements-http.test.ts)", async () => {
|
|
123
|
+
const card = (await rest(ownerToken, "POST", "/api/requirements", { network_id: NET, name: "REST 响应", owner: human() })).body.requirement;
|
|
124
|
+
for (const p of [{ agent_owner: nodeA }, { owner: human(), agent_owner: nodeB }, { participants: [nodeA] }]) {
|
|
125
|
+
const r = await rest(ownerToken, "PATCH", `/api/requirements/${card.id}`, p);
|
|
126
|
+
expect(r.status).toBe(200);
|
|
127
|
+
expect(r.body.requirement).toEqual((await rest(ownerToken, "GET", `/api/requirements/${card.id}`)).body.requirement);
|
|
128
|
+
}
|
|
129
|
+
// A new client (sends agent_owner) passing a node as owner: still strict, now with the hint.
|
|
130
|
+
const strict = await rest(ownerToken, "PATCH", `/api/requirements/${card.id}`, { owner: nodeA, agent_owner: nodeB });
|
|
131
|
+
expect(strict.status).toBe(400);
|
|
132
|
+
expect(strict.body.error).toBe("owner_must_be_human");
|
|
133
|
+
expect(strict.body.hint).toContain("agent_owner");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("old-app compat on REST is unchanged: owner=node without agent_owner is still coerced (200 + flag)", async () => {
|
|
137
|
+
const card = (await rest(ownerToken, "POST", "/api/requirements", { network_id: NET, name: "旧 App", owner: human() })).body.requirement;
|
|
138
|
+
const old = await rest(ownerToken, "PATCH", `/api/requirements/${card.id}`, { owner: nodeA });
|
|
139
|
+
expect(old.status).toBe(200);
|
|
140
|
+
expect(old.body.owner_coerced_to_agent_owner).toBe(true);
|
|
141
|
+
const row = stored(card.id);
|
|
142
|
+
expect(row.owner_json).toBeNull();
|
|
143
|
+
expect(JSON.parse(row.agent_owner_json!)).toEqual(nodeA);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -94,7 +94,7 @@ export function decodeCursor(raw: string): Cursor | null {
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
export type ListQuery = { limit: number; cursor: Cursor | null; terms: string[]; /** 整句当任务 ID 去对(taskIdQuery);空 = 没有搜索。 */ idQuery: string };
|
|
97
|
+
export type ListQuery = { limit: number; cursor: Cursor | null; terms: string[]; /** 整句当任务 ID 去对(taskIdQuery);空 = 没有搜索。 */ idQuery: string; /** #471 —— 只要带这个标签的卡(精确、区分大小写);null = 不按标签筛。 */ tag: string | null };
|
|
98
98
|
|
|
99
99
|
/**
|
|
100
100
|
* 任务 ID(同 App 的 task-short-id.ts matchesTaskId):「#42」「42」精确对短号 seq;完整 id,或 8 位以上的
|
|
@@ -134,5 +134,12 @@ export function parseListQuery(params: URLSearchParams): ListQuery | { error: st
|
|
|
134
134
|
const q = params.get("q") ?? "";
|
|
135
135
|
if (q.length > MAX_QUERY_LENGTH) return { error: "invalid_q" };
|
|
136
136
|
const terms = searchTerms(q);
|
|
137
|
-
|
|
137
|
+
// #471 —— tag=:和卡片标签同一套规范(去首尾空白、1–20 个字符、不含控制字符);不带 = 旧行为。
|
|
138
|
+
const rawTag = params.get("tag");
|
|
139
|
+
let tag: string | null = null;
|
|
140
|
+
if (rawTag !== null) {
|
|
141
|
+
tag = rawTag.trim();
|
|
142
|
+
if (!tag || [...tag].length > 20 || /[\u0000-\u001f\u007f]/u.test(tag)) return { error: "invalid_tag" };
|
|
143
|
+
}
|
|
144
|
+
return { limit, cursor, terms, idQuery: terms.length ? taskIdQuery(q) : "", tag };
|
|
138
145
|
}
|
package/src/requirements.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// 需求池。长期卡片,存在 Hub 上,手机和电脑读同一份。
|
|
2
2
|
// 不是 tasks:tasks 是正在派给节点的活,状态由节点收尾。
|
|
3
|
+
import { errorBody, errorFrom, RequirementFieldError, type ErrorContext } from "./requirements-errors.js";
|
|
3
4
|
import { createHash } from "node:crypto";
|
|
4
5
|
import { encodeCursor, matchesTaskId, matchesTerms, parseListQuery, type ListQuery, type NameMaps } from "./requirements-search.js";
|
|
5
6
|
import { db, logAudit } from "./db.js";
|
|
@@ -37,6 +38,12 @@ export type RequirementsRequestContext = {
|
|
|
37
38
|
isAdmin: boolean;
|
|
38
39
|
isNodeToken: boolean;
|
|
39
40
|
scope: RestNetworkScope;
|
|
41
|
+
/**
|
|
42
|
+
* #470 —— 负责人(owner)只收人,传节点一律 400 owner_must_be_human,不走旧 App 的「节点负责人 → 负责 Agent」兼容。
|
|
43
|
+
* MCP 工具(Agent 与人经 MCP 的调用)总是严格:那条兼容只为 App ≤ 0.2.142 的 REST 保存存在,Agent 走它会
|
|
44
|
+
* 静默清空人类负责人(task-lifecycle 测试报告问题 1)。REST 上带 agent_owner 的新客户端也严格(见 assignments)。
|
|
45
|
+
*/
|
|
46
|
+
strictOwner?: boolean;
|
|
40
47
|
};
|
|
41
48
|
|
|
42
49
|
const COLUMNS = new Set(["pool", "doing", "done"]);
|
|
@@ -78,8 +85,9 @@ type Row = {
|
|
|
78
85
|
completed_at_approx: number | null;
|
|
79
86
|
};
|
|
80
87
|
|
|
81
|
-
|
|
82
|
-
|
|
88
|
+
// #472:错误码原样,统一补 field / message / hint(requirements-errors.ts,唯一出口)。
|
|
89
|
+
function jsonError(error: string, status: number, ctx?: ErrorContext): Response {
|
|
90
|
+
return Response.json(errorBody(error, ctx), { status });
|
|
83
91
|
}
|
|
84
92
|
|
|
85
93
|
// ── 任务的人员权限(RFC-038 §9,判定在 task-access.ts) ──
|
|
@@ -261,10 +269,10 @@ function projectNameTaken(networkId: string, name: string, except?: string): boo
|
|
|
261
269
|
/** 卡片上的 project_id:null 清空;字符串必须是同一网络、未归档的项目。 */
|
|
262
270
|
function projectRef(value: unknown, networkId: string): string | null {
|
|
263
271
|
if (value === null) return null;
|
|
264
|
-
if (typeof value !== "string" || !value) throw new
|
|
272
|
+
if (typeof value !== "string" || !value) throw new RequirementFieldError("invalid_project", "project_id", value);
|
|
265
273
|
const row = db.get<ProjectRow>(`SELECT ${PROJECT_SELECT} FROM requirement_projects WHERE project_id = ?1`, value);
|
|
266
|
-
if (!row || row.network_id !== networkId) throw new
|
|
267
|
-
if (row.archived) throw new
|
|
274
|
+
if (!row || row.network_id !== networkId) throw new RequirementFieldError("project_not_in_network", "project_id", value);
|
|
275
|
+
if (row.archived) throw new RequirementFieldError("project_archived", "project_id", value);
|
|
268
276
|
return row.project_id;
|
|
269
277
|
}
|
|
270
278
|
|
|
@@ -512,15 +520,15 @@ const SELECT = "requirement_id, network_id, title, column_name, priority, due_on
|
|
|
512
520
|
"(SELECT COUNT(*) FROM requirements c WHERE c.parent_id = requirements.requirement_id AND COALESCE(c.archived, 0) = 0 AND c.column_name = 'done') AS children_done";
|
|
513
521
|
|
|
514
522
|
type PersonRef = { kind: 'user' | 'node'; id: string };
|
|
515
|
-
function personRef(value: unknown, networkId: string, hidden: HiddenNode = null): PersonRef {
|
|
516
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new
|
|
523
|
+
function personRef(value: unknown, networkId: string, hidden: HiddenNode = null, field = 'owner'): PersonRef {
|
|
524
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new RequirementFieldError('invalid_person', field);
|
|
517
525
|
const row = value as Record<string, unknown>;
|
|
518
|
-
if ((row.kind !== 'user' && row.kind !== 'node') || typeof row.id !== 'string' || !row.id.trim()) throw new
|
|
526
|
+
if ((row.kind !== 'user' && row.kind !== 'node') || typeof row.id !== 'string' || !row.id.trim()) throw new RequirementFieldError('invalid_person', field);
|
|
519
527
|
const exists = row.kind === 'user'
|
|
520
528
|
? db.get("SELECT 1 FROM network_members m JOIN users u ON u.user_id=m.user_id WHERE m.network_id=?1 AND m.user_id=?2", networkId, row.id)
|
|
521
529
|
: db.get("SELECT 1 FROM nodes WHERE network_id=?1 AND node_id=?2", networkId, row.id);
|
|
522
530
|
// 没授权的节点与不存在的节点同一个错误,不给受限成员留 node_id 探测差异。
|
|
523
|
-
if (!exists || (row.kind === 'node' && hidden?.(row.id as string))) throw new
|
|
531
|
+
if (!exists || (row.kind === 'node' && hidden?.(row.id as string))) throw new RequirementFieldError('person_not_in_network', field);
|
|
524
532
|
return { kind: row.kind, id: row.id };
|
|
525
533
|
}
|
|
526
534
|
|
|
@@ -529,7 +537,7 @@ function personRef(value: unknown, networkId: string, hidden: HiddenNode = null)
|
|
|
529
537
|
// 旧客户端兼容(App ≤ 0.2.142 只有一个「负责人」,可以选节点):请求里 owner 是节点、又**没带** agent_owner
|
|
530
538
|
// → 当成设置负责 Agent,负责人清空,照常 200,并回 owner_coerced_to_agent_owner: true。
|
|
531
539
|
// 带了 agent_owner 的就是新客户端,保持严格:owner 是节点 → 400 owner_must_be_human。
|
|
532
|
-
function assignments(body: Record<string, unknown>, networkId: string, current?: Row, hidden: HiddenNode = null) {
|
|
540
|
+
function assignments(body: Record<string, unknown>, networkId: string, current?: Row, hidden: HiddenNode = null, strictOwner = false) {
|
|
533
541
|
let owner = current?.owner_json ? JSON.parse(current.owner_json) : null;
|
|
534
542
|
let agentOwner = current?.agent_owner_json ? JSON.parse(current.agent_owner_json) : null;
|
|
535
543
|
let coerced: PersonRef | null = null;
|
|
@@ -538,7 +546,7 @@ function assignments(body: Record<string, unknown>, networkId: string, current?:
|
|
|
538
546
|
if ('owner' in body) {
|
|
539
547
|
owner = body.owner === null ? null : personRef(body.owner, networkId, hidden);
|
|
540
548
|
if (owner && owner.kind !== 'user') {
|
|
541
|
-
if ('agent_owner' in body) throw new Error('owner_must_be_human');
|
|
549
|
+
if (strictOwner || 'agent_owner' in body) throw new Error('owner_must_be_human');
|
|
542
550
|
coerced = owner;
|
|
543
551
|
agentOwner = owner;
|
|
544
552
|
owner = null;
|
|
@@ -547,13 +555,13 @@ function assignments(body: Record<string, unknown>, networkId: string, current?:
|
|
|
547
555
|
if (coerced && agentOwnerLocked) throw new Error('agent_owner_not_granted');
|
|
548
556
|
if ('agent_owner' in body) {
|
|
549
557
|
if (agentOwnerLocked) throw new Error('agent_owner_not_granted');
|
|
550
|
-
agentOwner = body.agent_owner === null ? null : personRef(body.agent_owner, networkId, hidden);
|
|
558
|
+
agentOwner = body.agent_owner === null ? null : personRef(body.agent_owner, networkId, hidden, 'agent_owner');
|
|
551
559
|
if (agentOwner && agentOwner.kind !== 'node') throw new Error('agent_owner_must_be_agent');
|
|
552
560
|
}
|
|
553
561
|
let participants = current ? JSON.parse(current.participants_json || '[]') : [];
|
|
554
562
|
if ('participants' in body) {
|
|
555
563
|
if (!Array.isArray(body.participants) || body.participants.length > 100) throw new Error('invalid_participants');
|
|
556
|
-
const refs = body.participants.map(value => personRef(value, networkId, hidden));
|
|
564
|
+
const refs = body.participants.map(value => personRef(value, networkId, hidden, 'participants'));
|
|
557
565
|
// 参与人是整体替换:把受限成员看不见的那些节点原样留下,别让他「保存」时静默删掉。
|
|
558
566
|
const keptHidden = hidden ? participants.filter((ref: unknown) => isHiddenRef(ref, hidden)) : [];
|
|
559
567
|
participants = [...new Map([...keptHidden, ...refs].map((ref: PersonRef) => [`${ref.kind}:${ref.id}`, ref])).values()];
|
|
@@ -741,7 +749,7 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
|
|
|
741
749
|
const scoped = !!caller && isTaskScoped(caller.userId, networkId);
|
|
742
750
|
let projectId: string | null = null;
|
|
743
751
|
if (body.project_id !== undefined) {
|
|
744
|
-
try { projectId = projectRef(body.project_id, networkId); } catch (e) { return
|
|
752
|
+
try { projectId = projectRef(body.project_id, networkId); } catch (e) { return errorFrom(e); }
|
|
745
753
|
// scoped 成员只能建进自己 can_edit 的项目;没授权的项目与不存在的项目同一个错误。
|
|
746
754
|
if (!canUseProject(caller, networkId, projectId)) return jsonError("project_not_in_network", 400);
|
|
747
755
|
}
|
|
@@ -760,7 +768,7 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
|
|
|
760
768
|
if (ref) {
|
|
761
769
|
const existing = rowByExternalRef(networkId, ref);
|
|
762
770
|
// 同一个外部条目再建一次 = 冲突,回已有的 id(同步方改用 upsert,或按这个 id PATCH)。
|
|
763
|
-
if (existing) return Response.json({
|
|
771
|
+
if (existing) return Response.json({ ...errorBody("external_ref_exists"), existing_id: existing.requirement_id }, { status: 409 });
|
|
764
772
|
}
|
|
765
773
|
if (clientId) {
|
|
766
774
|
const existing = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE network_id = ?1 AND client_id = ?2`, networkId, clientId);
|
|
@@ -770,7 +778,7 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
|
|
|
770
778
|
const id = `req_${crypto.randomUUID()}`;
|
|
771
779
|
const createdAt = new Date().toISOString();
|
|
772
780
|
let people;
|
|
773
|
-
try { people = assignments(body, networkId, undefined, hiddenNodeFilter(ctx, networkId)); } catch (e) { return
|
|
781
|
+
try { people = assignments(body, networkId, undefined, hiddenNodeFilter(ctx, networkId), ctx.strictOwner === true); } catch (e) { return errorFrom(e); }
|
|
774
782
|
const actor = JSON.stringify(actorOf(ctx));
|
|
775
783
|
try {
|
|
776
784
|
// 领号、插入、记「新建」这条动态在同一个事务里:插入被唯一索引挡下时,号和动态一起回滚,不留空洞。
|
|
@@ -790,7 +798,7 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
|
|
|
790
798
|
// 并发的同一个 external_ref / client_id:唯一索引挡住了第二个,回已有的那条。
|
|
791
799
|
if (ref) {
|
|
792
800
|
const existing = rowByExternalRef(networkId, ref);
|
|
793
|
-
if (existing) return Response.json({
|
|
801
|
+
if (existing) return Response.json({ ...errorBody("external_ref_exists"), existing_id: existing.requirement_id }, { status: 409 });
|
|
794
802
|
}
|
|
795
803
|
if (!clientId) return jsonError("insert_failed", 500);
|
|
796
804
|
const existing = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE network_id = ?1 AND client_id = ?2`, networkId, clientId);
|
|
@@ -805,7 +813,7 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
|
|
|
805
813
|
// ── 修改(省略的字段保留原值) ──
|
|
806
814
|
const PATCH_FIELDS = ["column", "issues", "tags", "name", "priority", "due", "assignee", "owner", "agent_owner", "participants", "description", "checklist", "project_id", "external_ref", "external_url", "archived", "parent_id", "start"];
|
|
807
815
|
function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Record<string, unknown>): Response {
|
|
808
|
-
if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400);
|
|
816
|
+
if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400, { sent: Object.keys(body), writable: PATCH_FIELDS });
|
|
809
817
|
const has = (k: string) => Object.prototype.hasOwnProperty.call(body, k);
|
|
810
818
|
if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
|
|
811
819
|
const caller = taskCaller(ctx);
|
|
@@ -856,10 +864,10 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
|
|
|
856
864
|
assignee = body.assignee.trim().slice(0, 80);
|
|
857
865
|
}
|
|
858
866
|
let people;
|
|
859
|
-
try { people = assignments(body, row.network_id, row, hiddenNodeFilter(ctx, row.network_id)); } catch (e) { return
|
|
867
|
+
try { people = assignments(body, row.network_id, row, hiddenNodeFilter(ctx, row.network_id), ctx.strictOwner === true); } catch (e) { return errorFrom(e); }
|
|
860
868
|
let projectId = row.project_id;
|
|
861
869
|
if (has("project_id")) {
|
|
862
|
-
try { projectId = projectRef(body.project_id, row.network_id); } catch (e) { return
|
|
870
|
+
try { projectId = projectRef(body.project_id, row.network_id); } catch (e) { return errorFrom(e); }
|
|
863
871
|
// scoped 成员只能挪进自己 can_edit 的项目;没授权的项目与不存在的项目同一个错误。
|
|
864
872
|
if (projectId !== row.project_id && !canUseProject(caller, row.network_id, projectId)) return jsonError("project_not_in_network", 400);
|
|
865
873
|
}
|
|
@@ -869,7 +877,7 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
|
|
|
869
877
|
if (next === undefined) return jsonError("invalid_external_ref", 400);
|
|
870
878
|
if (next && next !== row.external_ref) {
|
|
871
879
|
const taken = rowByExternalRef(row.network_id, next);
|
|
872
|
-
if (taken) return Response.json({
|
|
880
|
+
if (taken) return Response.json({ ...errorBody("external_ref_exists"), existing_id: taken.requirement_id }, { status: 409 });
|
|
873
881
|
}
|
|
874
882
|
ref = next;
|
|
875
883
|
}
|
|
@@ -950,7 +958,7 @@ function scopedRow(ctx: RequirementsRequestContext, id: string): Row | Response
|
|
|
950
958
|
const rows = db.all<Row>(`${sql} LIMIT 2`, ...params);
|
|
951
959
|
if (rows.length > 1) {
|
|
952
960
|
const networks = rows.map(r => r.network_id);
|
|
953
|
-
return Response.json({
|
|
961
|
+
return Response.json({ ...errorBody("ambiguous_seq"), networks, message: `#${seq} exists in more than one network you can see (${networks.join(", ")}); pass network_id to pick one` }, { status: 409 });
|
|
954
962
|
}
|
|
955
963
|
return rows[0] ?? jsonError("requirement_not_found", 404);
|
|
956
964
|
}
|
|
@@ -1014,7 +1022,7 @@ function listPage(ctx: RequirementsRequestContext, from: string, baseParams: unk
|
|
|
1014
1022
|
where += ` AND (created_at < ?${a} OR (created_at = ?${a} AND requirement_id < ?${b}))`;
|
|
1015
1023
|
}
|
|
1016
1024
|
let rows: Row[];
|
|
1017
|
-
if (!lq.terms.length) {
|
|
1025
|
+
if (!lq.terms.length && lq.tag === null) {
|
|
1018
1026
|
rows = db.all<Row>(`SELECT ${SELECT} ${where}${LIST_ORDER} LIMIT ${lq.limit + 1}`, ...params);
|
|
1019
1027
|
} else {
|
|
1020
1028
|
// seq(任务短号,#2139):启动迁移 ensureRequirementSeq 在 SQLite / PostgreSQL 上都加这一列,SELECT 本来就带它。
|
|
@@ -1025,7 +1033,9 @@ function listPage(ctx: RequirementsRequestContext, from: string, baseParams: unk
|
|
|
1025
1033
|
let m = maps.get(r.network_id);
|
|
1026
1034
|
if (!m) maps.set(r.network_id, m = searchNameMaps(ctx, r.network_id));
|
|
1027
1035
|
const row = { name: r.title, description: r.description || "", assignee: r.assignee || "", tags: storedTags(r.tags_json), project_id: r.project_id, owner: parseRef(r.owner_json), agent_owner: parseRef(r.agent_owner_json), participants: parseRef(r.participants_json) ?? [] };
|
|
1028
|
-
|
|
1036
|
+
// #471 —— tag= 在这里按存好的标签数组精确比(JSON 列,SQLite / PostgreSQL 写法不同,所以不进 SQL)。
|
|
1037
|
+
if (lq.tag !== null && !row.tags.includes(lq.tag)) continue;
|
|
1038
|
+
if (!lq.terms.length || matchesTaskId(r, lq.idQuery) || matchesTerms(row, lq.terms, m)) hits.push(r.requirement_id);
|
|
1029
1039
|
if (hits.length > lq.limit) break;
|
|
1030
1040
|
}
|
|
1031
1041
|
const byId = new Map(hits.length ? db.all<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id IN (${hits.map((_, i) => `?${i + 1}`).join(",")})`, ...hits).map(r => [r.requirement_id, r]) : []);
|
|
@@ -1436,7 +1446,7 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
|
|
|
1436
1446
|
if (req.method !== "PATCH") return jsonError("not_found", 404);
|
|
1437
1447
|
let body: Record<string, unknown>;
|
|
1438
1448
|
try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
|
|
1439
|
-
if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400);
|
|
1449
|
+
if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400, { sent: Object.keys(body), writable: PATCH_FIELDS });
|
|
1440
1450
|
const row = scopedRow(ctx, id);
|
|
1441
1451
|
if (row instanceof Response) return row;
|
|
1442
1452
|
return patchRequirement(ctx, row, body);
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// #464 —— 定时任务被一次没结束的执行卡住:告警 + 超时放行。
|
|
2
|
+
//
|
|
3
|
+
// 钉住:
|
|
4
|
+
// 1. 默认值:超时 = clamp(6 × 间隔, 1h, 24h);环境变量能改。
|
|
5
|
+
// 2. 同一个挡路任务连续挡掉 3 次 → 给排程创建者发恰好 1 条通知(别的成员 0 条);第 4、5 次不再发;
|
|
6
|
+
// 挡路任务一换(下一次真派出去了)重新上膛。
|
|
7
|
+
// 3. 挡路任务开着超过超时 → 它变成 expired(行还在、收件箱行 acked、run 记 expired/task_expired、
|
|
8
|
+
// error_message 说明是调度器超时),这一次照常派发。超时前已发过「卡住」→ 不再发;没发过 → 发一条「已超时」。
|
|
9
|
+
// 4. 晚到的回复:send_reply 以 reply_task_terminal 拒掉,旧 run / 旧任务 / 新任务 / 新 run 都不变,创建者不多收东西。
|
|
10
|
+
// 5. GET /runs 带 blocked_by_task_id / blocked_by_state。
|
|
11
|
+
//
|
|
12
|
+
// 跑法:cd server && COMMHUB_DB=/tmp/x.db bun test src/scheduled-stuck-http.test.ts
|
|
13
|
+
// PG:COMMHUB_TEST_PG_URL=… COMMHUB_PG_EXPERIMENTAL=1(tests/test2123-hub-postgres-ladder 里注册)
|
|
14
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
15
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
16
|
+
import { addNetworkMember, createNetworkTokenForNode, register } from "./auth.js";
|
|
17
|
+
import { db } from "./db.js";
|
|
18
|
+
import { dispatchScheduledOccurrence, handleScheduledTaskRequest } from "./scheduled-tasks.js";
|
|
19
|
+
import { SCHEDULE_STUCK_NOTICE_KIND, stuckTimeoutMs } from "./scheduled-stuck.js";
|
|
20
|
+
import { registerTools } from "./tools.js";
|
|
21
|
+
|
|
22
|
+
const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
|
|
23
|
+
if (!activeDbPath) throw new Error("scheduled-stuck requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
|
|
24
|
+
|
|
25
|
+
const stamp = Date.now();
|
|
26
|
+
const ALIAS = `stuck-node-${stamp}`;
|
|
27
|
+
const NODE_ID = `n_stuck_${stamp}`;
|
|
28
|
+
let NET = "", creatorId = "", otherId = "";
|
|
29
|
+
const ENV_KEYS = ["COMMHUB_SCHEDULE_STUCK_NOTICE_SKIPS", "COMMHUB_SCHEDULE_STUCK_TIMEOUT_FACTOR", "COMMHUB_SCHEDULE_STUCK_TIMEOUT_FLOOR_SEC", "COMMHUB_SCHEDULE_STUCK_TIMEOUT_CAP_SEC"];
|
|
30
|
+
|
|
31
|
+
function seedSchedule(name = "日报汇总"): any {
|
|
32
|
+
const scheduleId = `sched_${crypto.randomUUID()}`;
|
|
33
|
+
db.run(
|
|
34
|
+
`INSERT INTO scheduled_tasks
|
|
35
|
+
(schedule_id, network_id, created_by, name, target_node_id, target_alias, task_content, priority,
|
|
36
|
+
schedule_type, schedule_json, timezone, status, next_run_at)
|
|
37
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'scheduled task', 'normal', 'interval',
|
|
38
|
+
'{"type":"interval","every_seconds":60}', 'Asia/Shanghai', 'active', datetime('now', '+1 minute'))`,
|
|
39
|
+
[scheduleId, NET, creatorId, name, NODE_ID, ALIAS],
|
|
40
|
+
);
|
|
41
|
+
return db.get("SELECT * FROM scheduled_tasks WHERE schedule_id = ?1", scheduleId);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let slot = 0;
|
|
45
|
+
const nextSlot = () => new Date(Date.UTC(2026, 9, 2, 0, 0, 0) + (++slot) * 60_000).toISOString();
|
|
46
|
+
const fire = (schedule: any, now = new Date()) => dispatchScheduledOccurrence(schedule, nextSlot(), false, now);
|
|
47
|
+
|
|
48
|
+
const notices = (userId: string) => db.all<{ title: string; content: string; meta_json: string; from_session: string }>(
|
|
49
|
+
"SELECT title, content, meta_json, from_session FROM user_inbox WHERE user_id = ?1 AND network_id = ?2 AND kind = ?3 ORDER BY created_at, message_id",
|
|
50
|
+
userId, NET, SCHEDULE_STUCK_NOTICE_KIND,
|
|
51
|
+
);
|
|
52
|
+
const runRow = (runId: string) => db.get<any>("SELECT * FROM scheduled_task_runs WHERE run_id = ?1", runId);
|
|
53
|
+
const taskRow = (taskId: string) => db.get<any>("SELECT * FROM tasks WHERE task_id = ?1", taskId);
|
|
54
|
+
/** 把任务的开始时间往前拨(数据库里的 UTC 无时区串)。 */
|
|
55
|
+
const backdate = (taskId: string, ms: number) => db.run("UPDATE tasks SET created_at = ?1 WHERE task_id = ?2", [new Date(Date.now() - ms).toISOString().slice(0, 19).replace("T", " "), taskId]);
|
|
56
|
+
|
|
57
|
+
async function nodeReply(args: Record<string, unknown>): Promise<any> {
|
|
58
|
+
const mcp = new McpServer({ name: "sched-stuck", version: "0" }) as any;
|
|
59
|
+
const handlers: Record<string, (a: any) => Promise<any>> = {};
|
|
60
|
+
const original = mcp.tool.bind(mcp);
|
|
61
|
+
mcp.tool = (name: string, ...rest: any[]) => {
|
|
62
|
+
const handler = rest.at(-1);
|
|
63
|
+
if (typeof handler === "function") handlers[name] = handler;
|
|
64
|
+
return original(name, ...rest);
|
|
65
|
+
};
|
|
66
|
+
registerTools(mcp, undefined, NET, creatorId, ALIAS, true, null);
|
|
67
|
+
const out = await handlers.send_reply(args);
|
|
68
|
+
return JSON.parse(out.content[0].text);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
beforeAll(() => {
|
|
72
|
+
const creator = register(`stuck_creator_${stamp}`, "StuckCreator123!", undefined, "seed");
|
|
73
|
+
expect(creator.ok).toBe(true);
|
|
74
|
+
NET = creator.network_id!; creatorId = creator.user!.user_id;
|
|
75
|
+
const other = register(`stuck_other_${stamp}`, "StuckOther123!", undefined, "seed");
|
|
76
|
+
otherId = other.user!.user_id;
|
|
77
|
+
expect(addNetworkMember(NET, otherId, "member", creatorId, { agentAccess: "all" }).ok).toBe(true);
|
|
78
|
+
expect(createNetworkTokenForNode(creatorId, NET, ALIAS).ok).toBe(true);
|
|
79
|
+
db.run("INSERT INTO nodes (node_id, node_name, alias, runtime, network_id) VALUES (?1, ?2, ?2, 'claude-code', ?3)", [NODE_ID, ALIAS, NET]);
|
|
80
|
+
db.run("INSERT INTO sessions (resume_id, alias, status, node_id, network_id) VALUES (?1, ?2, 'idle', ?3, ?4)", [`r_stuck_${stamp}`, ALIAS, NODE_ID, NET]);
|
|
81
|
+
});
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
for (const k of ENV_KEYS) delete process.env[k];
|
|
84
|
+
db.run("DELETE FROM user_inbox WHERE network_id = ?1 AND kind = ?2", [NET, SCHEDULE_STUCK_NOTICE_KIND]);
|
|
85
|
+
// 上一个用例留下的开着的任务不许挡这一个(每个用例自己的排程,只按 schedule 查,保险起见也关掉)
|
|
86
|
+
db.run("UPDATE tasks SET status = 'replied' WHERE network_id = ?1 AND status IN ('created', 'delivered', 'acked', 'running')", [NET]);
|
|
87
|
+
});
|
|
88
|
+
afterEach(() => { for (const k of ENV_KEYS) delete process.env[k]; });
|
|
89
|
+
afterAll(() => { db.run("DELETE FROM user_inbox WHERE network_id = ?1 AND kind = ?2", [NET, SCHEDULE_STUCK_NOTICE_KIND]); });
|
|
90
|
+
|
|
91
|
+
describe("#464 defaults", () => {
|
|
92
|
+
test("timeout = clamp(6 × interval, 1h, 24h)", () => {
|
|
93
|
+
const iv = (s: number) => JSON.stringify({ type: "interval", every_seconds: s });
|
|
94
|
+
expect(stuckTimeoutMs(iv(60))).toBe(3_600_000);
|
|
95
|
+
expect(stuckTimeoutMs(iv(600))).toBe(3_600_000);
|
|
96
|
+
expect(stuckTimeoutMs(iv(1800))).toBe(3 * 3_600_000);
|
|
97
|
+
expect(stuckTimeoutMs(iv(3600))).toBe(6 * 3_600_000);
|
|
98
|
+
expect(stuckTimeoutMs(iv(86_400))).toBe(24 * 3_600_000);
|
|
99
|
+
expect(stuckTimeoutMs(JSON.stringify({ type: "daily", time: "09:00" }))).toBe(24 * 3_600_000);
|
|
100
|
+
expect(stuckTimeoutMs(JSON.stringify({ type: "once", run_at: "2026-10-02T00:00:00Z" }))).toBe(3_600_000);
|
|
101
|
+
process.env.COMMHUB_SCHEDULE_STUCK_TIMEOUT_FACTOR = "0";
|
|
102
|
+
process.env.COMMHUB_SCHEDULE_STUCK_TIMEOUT_FLOOR_SEC = "20";
|
|
103
|
+
expect(stuckTimeoutMs(iv(60))).toBe(20_000);
|
|
104
|
+
process.env.COMMHUB_SCHEDULE_STUCK_TIMEOUT_FLOOR_SEC = "junk";
|
|
105
|
+
expect(stuckTimeoutMs(iv(60))).toBe(3_600_000);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("#464 stuck notice", () => {
|
|
110
|
+
test("3rd consecutive skip behind the same task → exactly one notice to the creator, none to others", () => {
|
|
111
|
+
const s = seedSchedule();
|
|
112
|
+
const first = fire(s);
|
|
113
|
+
expect(first.status).toBe("delivered");
|
|
114
|
+
const skips = [fire(s), fire(s)];
|
|
115
|
+
expect(notices(creatorId).length).toBe(0);
|
|
116
|
+
skips.push(fire(s), fire(s), fire(s));
|
|
117
|
+
expect(skips.map((r) => r.status)).toEqual(["skipped", "skipped", "skipped", "skipped", "skipped"]);
|
|
118
|
+
const mine = notices(creatorId);
|
|
119
|
+
expect(mine.length).toBe(1);
|
|
120
|
+
expect(notices(otherId).length).toBe(0);
|
|
121
|
+
const n = mine[0];
|
|
122
|
+
expect(n.from_session).toBe(ALIAS);
|
|
123
|
+
expect(n.title).toBe("定时任务被卡住");
|
|
124
|
+
for (const frag of ["日报汇总", first.taskId!, ALIAS, "(Asia/Shanghai)", "3 次", "1 小时"]) expect(n.content).toContain(frag);
|
|
125
|
+
expect(JSON.parse(n.meta_json).schedule_stuck).toEqual({ reason: "stuck", schedule_id: s.schedule_id, blocked_by_task_id: first.taskId, skips: 3 });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("re-armed once a run is dispatched again: a second episode notifies again", () => {
|
|
129
|
+
const s = seedSchedule();
|
|
130
|
+
const first = fire(s);
|
|
131
|
+
for (let i = 0; i < 4; i++) fire(s);
|
|
132
|
+
db.run("UPDATE tasks SET status = 'replied', completed_at = datetime('now') WHERE task_id = ?1", [first.taskId]);
|
|
133
|
+
const second = fire(s);
|
|
134
|
+
expect(second.status).toBe("delivered");
|
|
135
|
+
for (let i = 0; i < 3; i++) fire(s);
|
|
136
|
+
const mine = notices(creatorId);
|
|
137
|
+
expect(mine.length).toBe(2);
|
|
138
|
+
// 两条通知同一秒写入,created_at 排不出先后:按挡路任务比集合。
|
|
139
|
+
expect(mine.map((m) => JSON.parse(m.meta_json).schedule_stuck.blocked_by_task_id).sort()).toEqual([first.taskId, second.taskId].sort());
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("COMMHUB_SCHEDULE_STUCK_NOTICE_SKIPS changes N", () => {
|
|
143
|
+
process.env.COMMHUB_SCHEDULE_STUCK_NOTICE_SKIPS = "1";
|
|
144
|
+
const s = seedSchedule();
|
|
145
|
+
fire(s); fire(s);
|
|
146
|
+
expect(notices(creatorId).length).toBe(1);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("schedule without a creator → no notice, skips still recorded", () => {
|
|
150
|
+
const s = seedSchedule();
|
|
151
|
+
db.run("UPDATE scheduled_tasks SET created_by = NULL WHERE schedule_id = ?1", [s.schedule_id]);
|
|
152
|
+
const row = db.get("SELECT * FROM scheduled_tasks WHERE schedule_id = ?1", s.schedule_id);
|
|
153
|
+
fire(row); for (let i = 0; i < 4; i++) expect(fire(row).status).toBe("skipped");
|
|
154
|
+
expect(notices(creatorId).length).toBe(0);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("#464 stuck timeout", () => {
|
|
159
|
+
test("blocker past the timeout is expired (row kept) and this occurrence dispatches; no second notice", () => {
|
|
160
|
+
const s = seedSchedule();
|
|
161
|
+
const first = fire(s);
|
|
162
|
+
db.run("UPDATE tasks SET status = 'running' WHERE task_id = ?1", [first.taskId]);
|
|
163
|
+
for (let i = 0; i < 3; i++) fire(s);
|
|
164
|
+
expect(notices(creatorId).length).toBe(1);
|
|
165
|
+
|
|
166
|
+
backdate(first.taskId!, 59 * 60_000);
|
|
167
|
+
expect(fire(s).status).toBe("skipped"); // 59 分钟 < 1 小时
|
|
168
|
+
backdate(first.taskId!, 61 * 60_000);
|
|
169
|
+
const next = fire(s);
|
|
170
|
+
expect(next.status).toBe("delivered");
|
|
171
|
+
expect(next.taskId).not.toBe(first.taskId);
|
|
172
|
+
|
|
173
|
+
const old = taskRow(first.taskId!);
|
|
174
|
+
expect(old.status).toBe("expired");
|
|
175
|
+
expect(old.content).toBe("scheduled task");
|
|
176
|
+
expect(db.get<any>("SELECT acked FROM inbox WHERE task_id = ?1", first.taskId).acked).toBeTruthy();
|
|
177
|
+
const oldRun = runRow(first.runId);
|
|
178
|
+
expect(oldRun.status).toBe("expired");
|
|
179
|
+
expect(oldRun.error_code).toBe("task_expired");
|
|
180
|
+
expect(oldRun.error_message).toContain("timed out by the scheduler");
|
|
181
|
+
expect(db.get<any>("SELECT COUNT(*) AS n FROM task_events WHERE task_id = ?1 AND to_status = 'expired' AND actor = 'hub-scheduler'", first.taskId).n).toBe(1);
|
|
182
|
+
expect(notices(creatorId).length).toBe(1); // 每段卡住恰好一条
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("timed out before N skips (long interval) → one 'timed out' notice instead", () => {
|
|
186
|
+
const s = seedSchedule("每日巡检");
|
|
187
|
+
const first = fire(s);
|
|
188
|
+
expect(fire(s).status).toBe("skipped");
|
|
189
|
+
backdate(first.taskId!, 2 * 3_600_000);
|
|
190
|
+
expect(fire(s).status).toBe("delivered");
|
|
191
|
+
const mine = notices(creatorId);
|
|
192
|
+
expect(mine.length).toBe(1);
|
|
193
|
+
expect(mine[0].title).toBe("定时任务已超时放行");
|
|
194
|
+
expect(mine[0].content).toContain(first.taskId!);
|
|
195
|
+
expect(JSON.parse(mine[0].meta_json).schedule_stuck.reason).toBe("timed_out");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("a late reply to the expired task is refused and changes nothing", async () => {
|
|
199
|
+
const s = seedSchedule();
|
|
200
|
+
const first = fire(s);
|
|
201
|
+
backdate(first.taskId!, 2 * 3_600_000);
|
|
202
|
+
const next = fire(s);
|
|
203
|
+
expect(next.status).toBe("delivered");
|
|
204
|
+
const inboxBefore = db.get<any>("SELECT COUNT(*) AS n FROM inbox WHERE network_id = ?1", NET).n;
|
|
205
|
+
const out = await nodeReply({ alias: "scheduler", text: "late answer", in_reply_to: first.taskId, status: "replied", from_session: ALIAS });
|
|
206
|
+
expect(out.ok).toBe(false);
|
|
207
|
+
expect(out.error).toBe("reply_task_terminal");
|
|
208
|
+
expect(out.reply_queued).toBe(false);
|
|
209
|
+
expect(taskRow(first.taskId!).status).toBe("expired");
|
|
210
|
+
expect(runRow(first.runId).status).toBe("expired");
|
|
211
|
+
expect(taskRow(next.taskId!).status).toBe("delivered");
|
|
212
|
+
expect(runRow(next.runId).status).toBe("delivered");
|
|
213
|
+
expect(db.get<any>("SELECT COUNT(*) AS n FROM inbox WHERE network_id = ?1", NET).n).toBe(inboxBefore);
|
|
214
|
+
// 新那条照常能被回复,run 照常收尾
|
|
215
|
+
const ok = await nodeReply({ alias: "scheduler", text: "on time", in_reply_to: next.taskId, status: "replied", from_session: ALIAS });
|
|
216
|
+
expect(ok.ok).toBe(true);
|
|
217
|
+
expect(runRow(next.runId).status).toBe("replied");
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe("#464 history clarity", () => {
|
|
222
|
+
test("GET /runs exposes blocked_by_task_id and blocked_by_state on skipped rows", async () => {
|
|
223
|
+
const s = seedSchedule();
|
|
224
|
+
const first = fire(s);
|
|
225
|
+
const second = fire(s);
|
|
226
|
+
const url = new URL(`http://hub.test/api/scheduled-tasks/${s.schedule_id}/runs`);
|
|
227
|
+
const res = await handleScheduledTaskRequest({
|
|
228
|
+
req: new Request(url, { method: "GET" }), url,
|
|
229
|
+
auth: { userId: creatorId, networkId: NET, username: "creator" },
|
|
230
|
+
isAdmin: false, isNodeToken: false, scope: { networkId: NET, networkIds: [NET] } as any,
|
|
231
|
+
});
|
|
232
|
+
const body = await res!.json() as any;
|
|
233
|
+
const skipped = body.runs.find((r: any) => r.run_id === second.runId);
|
|
234
|
+
expect(skipped.status).toBe("skipped");
|
|
235
|
+
expect(skipped.blocked_by_task_id).toBe(first.taskId);
|
|
236
|
+
expect(skipped.blocked_by_state).toBe("not_received");
|
|
237
|
+
});
|
|
238
|
+
});
|