@sleep2agi/commhub-server 0.9.0-preview.97 → 0.9.0-preview.99
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/requirements-last-event-http.test.ts +107 -0
- package/src/requirements.ts +10 -1
- package/src/server.ts +17 -5
- package/src/task-expiry-notice-http.test.ts +287 -0
- package/src/task-expiry-notice.ts +307 -0
- package/src/tools.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.99",
|
|
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",
|
|
@@ -235,3 +235,110 @@ describe("#506 GET /api/requirements last_event", () => {
|
|
|
235
235
|
}
|
|
236
236
|
});
|
|
237
237
|
});
|
|
238
|
+
|
|
239
|
+
// #506 跟进 —— 评论不动 updated_at,所以增量读(changes=1 + updated_since)过去看不见它:「评论了」要等下次整读。
|
|
240
|
+
// 现在 changes=1(且带 last_event)时,游标之后有新流水的卡也回。updated_at 语义不变(评论不改卡)。
|
|
241
|
+
describe("#506 changes=1 returns cards whose newest event is after the cursor", () => {
|
|
242
|
+
const tick = () => new Promise(r => setTimeout(r, 5));
|
|
243
|
+
const changes = (t: string, since: string, extra = "", headers: Record<string, string> = {}) =>
|
|
244
|
+
list(t, `&changes=1&updated_since=${encodeURIComponent(since)}${extra}`, headers);
|
|
245
|
+
const cursorNow = async (t: string) => {
|
|
246
|
+
const base = await changes(t, new Date().toISOString());
|
|
247
|
+
expect(base.status).toBe(200);
|
|
248
|
+
await tick();
|
|
249
|
+
return base.body.server_time as string;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
test("a comment after the cursor → that row comes back with the new last_event; untouched rows do not", async () => {
|
|
253
|
+
const a = await create(admin.token, { name: "增量评论 A" });
|
|
254
|
+
const b = await create(admin.token, { name: "增量评论 B" });
|
|
255
|
+
const before = rowOf(await list(admin.token), a.id);
|
|
256
|
+
await tick();
|
|
257
|
+
const since = await cursorNow(admin.token);
|
|
258
|
+
expect((await comment(member.token, a.id, "增量里看得到这条")).status).toBe(201);
|
|
259
|
+
const r = await changes(admin.token, since);
|
|
260
|
+
expect(r.status).toBe(200);
|
|
261
|
+
const ids = (r.body.requirements as any[]).map(x => x.id);
|
|
262
|
+
expect(ids).toEqual([a.id]);
|
|
263
|
+
expect(ids).not.toContain(b.id);
|
|
264
|
+
const row = rowOf(r, a.id);
|
|
265
|
+
expect(row.updatedAt).toBe(before.updatedAt); // updated_at 语义不变:评论不改卡
|
|
266
|
+
expect(row.last_event).toMatchObject({ type: "comment", actor: { id: member.id, kind: "user" }, summary: "增量里看得到这条" });
|
|
267
|
+
expect(r.body.deleted).toEqual([]);
|
|
268
|
+
// summary 视图同样回
|
|
269
|
+
const s = await changes(admin.token, since, "&view=summary");
|
|
270
|
+
expect((s.body.requirements as any[]).map(x => x.id)).toEqual([a.id]);
|
|
271
|
+
expect(rowOf(s, a.id).last_event.summary).toBe("增量里看得到这条");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("no event and no update after the cursor → nothing returned; a comment then changes the result and its ETag", async () => {
|
|
275
|
+
const a = await create(admin.token, { name: "增量空" });
|
|
276
|
+
await tick();
|
|
277
|
+
const since = await cursorNow(admin.token);
|
|
278
|
+
const empty = await changes(admin.token, since);
|
|
279
|
+
expect(empty.status).toBe(200);
|
|
280
|
+
expect(empty.body.requirements).toEqual([]);
|
|
281
|
+
expect(empty.body.deleted).toEqual([]);
|
|
282
|
+
expect(empty.etag).toBeTruthy();
|
|
283
|
+
expect((await comment(admin.token, a.id, "空之后的评论")).status).toBe(201);
|
|
284
|
+
const after = await changes(admin.token, since, "", { "If-None-Match": empty.etag! });
|
|
285
|
+
expect(after.status).toBe(200);
|
|
286
|
+
expect(after.etag).not.toBe(empty.etag);
|
|
287
|
+
expect((after.body.requirements as any[]).map(x => x.id)).toEqual([a.id]);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("full-list ETag changes on a comment (list cache invalidated by the write)", async () => {
|
|
291
|
+
const a = await create(admin.token, { name: "整表 ETag" });
|
|
292
|
+
const first = await list(admin.token);
|
|
293
|
+
expect((await list(admin.token, "", { "If-None-Match": first.etag! })).status).toBe(304);
|
|
294
|
+
expect((await comment(admin.token, a.id, "整表也变")).status).toBe(201);
|
|
295
|
+
const after = await list(admin.token, "", { "If-None-Match": first.etag! });
|
|
296
|
+
expect(after.status).toBe(200);
|
|
297
|
+
expect(after.etag).not.toBe(first.etag);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("unchanged contracts: last_event=0 (MCP default) and plain updated_since (no changes=1) still filter by updated_at only", async () => {
|
|
301
|
+
const a = await create(admin.token, { name: "旧语义" });
|
|
302
|
+
await tick();
|
|
303
|
+
const since = await cursorNow(admin.token);
|
|
304
|
+
expect((await comment(admin.token, a.id, "只是一条评论")).status).toBe(201);
|
|
305
|
+
expect((await changes(admin.token, since, "&last_event=0")).body.requirements).toEqual([]);
|
|
306
|
+
const plain = await list(admin.token, `&updated_since=${encodeURIComponent(since)}`);
|
|
307
|
+
expect(plain.status).toBe(200);
|
|
308
|
+
expect(rowOf(plain, a.id)).toBeUndefined();
|
|
309
|
+
// MCP changes=true 默认不带 last_event → 同样不回;include_last_event=true → 回
|
|
310
|
+
const mDef = await mcp(nodeToken, "requirements_list", { changes: true, updated_since: since });
|
|
311
|
+
expect(mDef.isError).toBe(false);
|
|
312
|
+
expect(mDef.data.requirements.map((r: any) => r.id)).not.toContain(a.id);
|
|
313
|
+
const mOn = await mcp(nodeToken, "requirements_list", { changes: true, updated_since: since, include_last_event: true });
|
|
314
|
+
expect(mOn.isError).toBe(false);
|
|
315
|
+
expect(mOn.data.requirements.find((r: any) => r.id === a.id)?.last_event).toMatchObject({ type: "comment", summary: "只是一条评论" });
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("a real update after the cursor still comes back (updated_at path); a delete is reported in `deleted`, not as a row", async () => {
|
|
319
|
+
const a = await create(admin.token, { name: "增量改动" });
|
|
320
|
+
const d = await create(admin.token, { name: "增量删除" });
|
|
321
|
+
await comment(admin.token, d.id, "删之前的评论");
|
|
322
|
+
await tick();
|
|
323
|
+
const since = await cursorNow(admin.token);
|
|
324
|
+
expect((await send(admin.token, "PATCH", `/api/requirements/${a.id}?network_id=${NET}`, { priority: "high" })).status).toBe(200);
|
|
325
|
+
expect((await send(admin.token, "DELETE", `/api/requirements/${d.id}?network_id=${NET}`)).status).toBe(200);
|
|
326
|
+
const r = await changes(admin.token, since);
|
|
327
|
+
expect((r.body.requirements as any[]).map(x => x.id)).toEqual([a.id]);
|
|
328
|
+
expect(rowOf(r, a.id).last_event).toMatchObject({ type: "changed", field: "priority" });
|
|
329
|
+
expect(r.body.deleted).toEqual([d.id]);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("visibility: a scoped member does not get a card it cannot see, even when it was commented on", async () => {
|
|
333
|
+
const hidden = await create(admin.token, { name: "scoped 看不见" });
|
|
334
|
+
const mine = await create(admin.token, { name: "scoped 看得见", owner: { kind: "user", id: scoped.id } });
|
|
335
|
+
await tick();
|
|
336
|
+
const since = await cursorNow(scoped.token);
|
|
337
|
+
expect((await comment(admin.token, hidden.id, "别人的卡上的评论")).status).toBe(201);
|
|
338
|
+
expect((await comment(admin.token, mine.id, "他的卡上的评论")).status).toBe(201);
|
|
339
|
+
const r = await changes(scoped.token, since);
|
|
340
|
+
expect(r.status).toBe(200);
|
|
341
|
+
expect((r.body.requirements as any[]).map(x => x.id)).toEqual([mine.id]);
|
|
342
|
+
expect(JSON.stringify(r.body)).not.toContain(hidden.id);
|
|
343
|
+
});
|
|
344
|
+
});
|
package/src/requirements.ts
CHANGED
|
@@ -1105,7 +1105,16 @@ function listFilters(url: URL, sql: string, params: unknown[], ctx?: Requirement
|
|
|
1105
1105
|
if (since !== null) {
|
|
1106
1106
|
const ms = Date.parse(since);
|
|
1107
1107
|
if (!Number.isFinite(ms)) return jsonError("invalid_updated_since", 400);
|
|
1108
|
-
|
|
1108
|
+
const n = params.push(new Date(ms).toISOString());
|
|
1109
|
+
// #506 —— 增量(changes=1)且带 last_event 时,「之后有新流水」的卡也算改过:评论只追加 requirement_events、
|
|
1110
|
+
// 不动 updated_at(updatedAt 是「卡本身最后一次被改」,评论不算 —— 见 last_event 测试),不这样评论要等下次整读才出现。
|
|
1111
|
+
// 同一个占位符用两次(SQLite / PostgreSQL 都认,没有绑了不用的参数);子查询走 idx_requirement_events_created,
|
|
1112
|
+
// 只扫游标之后的流水。不带 changes 的 updated_since 照旧只按 updated_at 过滤(同步方 / MCP 筛选语义不变);
|
|
1113
|
+
// last_event=0(MCP 默认)行上没有动态可看,也照旧。
|
|
1114
|
+
const eventsToo = q.get("changes") === "1" && q.get("last_event") !== "0";
|
|
1115
|
+
sql += eventsToo
|
|
1116
|
+
? ` AND (updated_at >= ?${n} OR requirement_id IN (SELECT requirement_id FROM requirement_events WHERE created_at >= ?${n}))`
|
|
1117
|
+
: ` AND updated_at >= ?${n}`;
|
|
1109
1118
|
}
|
|
1110
1119
|
const seq = q.get("seq");
|
|
1111
1120
|
if (seq !== null) {
|
package/src/server.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { recentNodeIdentityConflicts } from "./node-identity-conflict.js";
|
|
|
22
22
|
import { createSSEStream, createNetworkObserverStream, createUserEventStream, pushEvent, pushNetworkObserverEvent, getSSEStats, PRINTABLE_OBSERVER_KEY_PREFIX, closeUserStreamsInNetwork, getUserPresence, onUserPresenceChange, pushUserEventToNetwork } from "./push.js";
|
|
23
23
|
import { assertNodeActive } from "./lifecycle-guard.js";
|
|
24
24
|
import { pendingInboxCount } from "./inbox-count.js";
|
|
25
|
+
import { notifyExpiredTasks, type ExpiredTaskRow } from "./task-expiry-notice.js";
|
|
25
26
|
import { addAgentNetworkScope, addHumanNetworkScope, addNetworkScope, addOwnTrafficScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, resolveRestWriteNetworkId, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
26
27
|
import { restrictedMemberSeesFile, restrictedMemberAttachmentsDenied } from "./restricted-files.js";
|
|
27
28
|
import { dmParticipantSeesFile, listDmThread, listDmThreads, sendHumanDm } from "./human-dm.js";
|
|
@@ -891,16 +892,24 @@ const wsTmuxIntervals = new Map<object, ReturnType<typeof setInterval>>();
|
|
|
891
892
|
export function patrolExpiredTasks(): void {
|
|
892
893
|
try {
|
|
893
894
|
const expired = db.transaction(() => {
|
|
894
|
-
|
|
895
|
-
|
|
895
|
+
// #500 — a task the node already took into a turn before its deadline
|
|
896
|
+
// (consumed_at < expires_at) is not "never picked up": the status only
|
|
897
|
+
// stays `delivered` because delivered → running needs a verbatim
|
|
898
|
+
// report_status match. Leave it for the node to finish. A consumed_at
|
|
899
|
+
// stamped AFTER the deadline (late receipt replay) does not save it.
|
|
900
|
+
const due = db.all<ExpiredTaskRow>(
|
|
901
|
+
`SELECT task_id, network_id, from_name, from_node_id, to_name, content, created_at, expires_at, parent_task_id, meta_json
|
|
902
|
+
FROM tasks
|
|
896
903
|
WHERE expires_at IS NOT NULL AND expires_at < datetime('now')
|
|
897
|
-
AND status IN ('created', 'delivered')
|
|
904
|
+
AND status IN ('created', 'delivered')
|
|
905
|
+
AND (consumed_at IS NULL OR consumed_at >= expires_at)`,
|
|
898
906
|
);
|
|
899
|
-
const changed:
|
|
907
|
+
const changed: ExpiredTaskRow[] = [];
|
|
900
908
|
for (const task of due) {
|
|
901
909
|
const result = db.run(
|
|
902
910
|
`UPDATE tasks SET status = 'expired', completed_at = datetime('now')
|
|
903
|
-
WHERE task_id = ?1 AND status IN ('created', 'delivered')
|
|
911
|
+
WHERE task_id = ?1 AND status IN ('created', 'delivered')
|
|
912
|
+
AND (consumed_at IS NULL OR consumed_at >= expires_at)`,
|
|
904
913
|
[task.task_id],
|
|
905
914
|
);
|
|
906
915
|
// SQLite may include the AFTER UPDATE terminal-journal trigger write
|
|
@@ -925,6 +934,9 @@ export function patrolExpiredTasks(): void {
|
|
|
925
934
|
if (expired.length > 0) {
|
|
926
935
|
console.log(`[patrol] expired ${expired.length} stale task(s)`);
|
|
927
936
|
for (const task of expired) logTaskEvent(task.task_id, null, "expired", "patrol");
|
|
937
|
+
// #500 — tell the sender, after commit (task-expiry-notice.ts).
|
|
938
|
+
const notices = notifyExpiredTasks(expired);
|
|
939
|
+
if (notices.length > 0) console.log(`[patrol] sent ${notices.length} expiry notice(s)`);
|
|
928
940
|
}
|
|
929
941
|
} catch {}
|
|
930
942
|
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// #500 —— 任务过期时告诉派活的人;节点已经取走的任务不再被过期。HTTP 集成测试(真实 Bun.serve,私有端口,临时库)。
|
|
2
|
+
// test2123 在真实 PostgreSQL 上原样再跑一遍(COMMHUB_TEST_PG_URL)。
|
|
3
|
+
//
|
|
4
|
+
// 任务一律经真入口 POST /api/task 派出(节点令牌 / 用户令牌),只把 created_at / expires_at 往前拨,
|
|
5
|
+
// 然后调 TTL 巡检(server.ts patrolExpiredTasks,定时器调的就是它)。收件一侧也走真入口:
|
|
6
|
+
// agent 用自己的节点令牌订阅 /events/<alias> 并经 /mcp get_inbox 取;人订阅 /events/users/me。
|
|
7
|
+
//
|
|
8
|
+
// 钉住:
|
|
9
|
+
// 1. consumed_at 早于期限 → 不过期、不通知;consumed_at 晚于期限(迟到的回执)→ 照样过期。
|
|
10
|
+
// 2. agent 派的任务过期 → 它收到恰好一条 type=reply / from=hub / requires_response=none 的通知 + SSE new_reply;
|
|
11
|
+
// 正文带目标别名、等了几分钟、前面还有几个、retry_task、「不要原样重发」。
|
|
12
|
+
// 3. 人派的 → user_inbox kind=task_expired 一条,from_session = 目标节点(出现在与它的会话里)+ SSE desktop_message。
|
|
13
|
+
// 4. scheduler 派的 → 谁都不通知。
|
|
14
|
+
// 5. 子任务过期 → 父任务的发送方收到一条「子任务已过期」,父任务状态 / result 不变。
|
|
15
|
+
// 6. 同一次巡检里同一 (发送方, 目标) 5 条过期 → 只发一条,列出 5 个 id。
|
|
16
|
+
//
|
|
17
|
+
// 跑法:cd server && COMMHUB_DB=/tmp/x.db bun test src/task-expiry-notice-http.test.ts
|
|
18
|
+
|
|
19
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
20
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
|
|
24
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-expiry-notice-"));
|
|
25
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
26
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
27
|
+
process.env.HOST = "127.0.0.1";
|
|
28
|
+
const PW = "ExpiryNotice123!x";
|
|
29
|
+
const stamp = Date.now();
|
|
30
|
+
|
|
31
|
+
let db: any;
|
|
32
|
+
let patrol: () => void;
|
|
33
|
+
let KIND = "";
|
|
34
|
+
let hub: any = null;
|
|
35
|
+
let BASE = "";
|
|
36
|
+
let NET = "";
|
|
37
|
+
let bossToken = "", umaToken = "", umaId = "", umaName = "";
|
|
38
|
+
type Node = { alias: string; nodeId: string; token: string };
|
|
39
|
+
const nodes: Record<"sender" | "target" | "other", Node> = {} as any;
|
|
40
|
+
|
|
41
|
+
async function send(token: string, method: string, path: string, payload?: unknown) {
|
|
42
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
43
|
+
method,
|
|
44
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
45
|
+
body: payload === undefined ? undefined : JSON.stringify(payload),
|
|
46
|
+
});
|
|
47
|
+
return { status: res.status, body: await res.json().catch(() => null) as any };
|
|
48
|
+
}
|
|
49
|
+
async function tool(token: string, name: string, args: Record<string, unknown>) {
|
|
50
|
+
const res = await fetch(`${BASE}/mcp`, {
|
|
51
|
+
method: "POST",
|
|
52
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
|
|
53
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
|
|
54
|
+
});
|
|
55
|
+
const raw = await res.text();
|
|
56
|
+
const data = raw.split("\n").filter((x) => x.startsWith("data:"));
|
|
57
|
+
const payload = data.length ? JSON.parse(data.at(-1)!.slice(5).trim()) : JSON.parse(raw);
|
|
58
|
+
return JSON.parse(payload.result.content[0].text);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type Sub = { events: any[]; close: () => void };
|
|
62
|
+
async function subscribe(path: string, token: string): Promise<Sub> {
|
|
63
|
+
const ctrl = new AbortController();
|
|
64
|
+
const res = await fetch(`${BASE}${path}`, { headers: { Authorization: `Bearer ${token}` }, signal: ctrl.signal });
|
|
65
|
+
expect(res.status).toBe(200);
|
|
66
|
+
const events: any[] = [];
|
|
67
|
+
const reader = res.body!.getReader();
|
|
68
|
+
const dec = new TextDecoder();
|
|
69
|
+
let buf = "";
|
|
70
|
+
void (async () => {
|
|
71
|
+
try {
|
|
72
|
+
for (;;) {
|
|
73
|
+
const { done, value } = await reader.read();
|
|
74
|
+
if (done) break;
|
|
75
|
+
buf += dec.decode(value);
|
|
76
|
+
let i: number;
|
|
77
|
+
while ((i = buf.indexOf("\n\n")) >= 0) {
|
|
78
|
+
const frame = buf.slice(0, i); buf = buf.slice(i + 2);
|
|
79
|
+
for (const line of frame.split("\n")) if (line.startsWith("data: ")) { try { events.push(JSON.parse(line.slice(6))); } catch {} }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
} catch {}
|
|
83
|
+
})();
|
|
84
|
+
await until(() => events.some((e) => e.type === "connected"));
|
|
85
|
+
return { events, close: () => ctrl.abort() };
|
|
86
|
+
}
|
|
87
|
+
const until = async (cond: () => boolean, tries = 150) => { for (let k = 0; k < tries && !cond(); k++) await new Promise((r) => setTimeout(r, 20)); };
|
|
88
|
+
|
|
89
|
+
let seq = 0;
|
|
90
|
+
/** 经 POST /api/task 派一条;返回 task_id。 */
|
|
91
|
+
async function dispatch(token: string, to: string, extra: Record<string, unknown> = {}): Promise<string> {
|
|
92
|
+
const r = await send(token, "POST", "/api/task", { alias: to, task: `expiry case ${stamp} #${++seq}`, network_id: NET, ...extra });
|
|
93
|
+
expect(r.status).toBe(200);
|
|
94
|
+
expect(r.body.ok).toBe(true);
|
|
95
|
+
return r.body.task_id;
|
|
96
|
+
}
|
|
97
|
+
/** 把这条任务拨成「61 分钟前派出、1 分钟前到期」。 */
|
|
98
|
+
const backdate = (taskId: string) => db.run(
|
|
99
|
+
"UPDATE tasks SET created_at = datetime('now', '-61 minutes'), delivered_at = datetime('now', '-61 minutes'), expires_at = datetime('now', '-1 minutes') WHERE task_id = ?1",
|
|
100
|
+
[taskId],
|
|
101
|
+
);
|
|
102
|
+
const taskRow = (taskId: string) => db.get("SELECT status, result FROM tasks WHERE task_id = ?1", taskId) as { status: string; result: string | null };
|
|
103
|
+
const agentNotices = (alias: string) => db.all(
|
|
104
|
+
"SELECT id, type, content, from_session, in_reply_to, requires_response, meta_json, acked FROM inbox WHERE session_name = ?1 AND network_id = ?2 AND from_session = 'hub' AND id LIKE 'exp_%' ORDER BY created_at, id",
|
|
105
|
+
alias, NET,
|
|
106
|
+
) as Array<{ id: string; type: string; content: string; from_session: string; in_reply_to: string; requires_response: string; meta_json: string; acked: number }>;
|
|
107
|
+
const userNotices = (userId: string) => db.all(
|
|
108
|
+
"SELECT message_id, from_session, title, content, meta_json FROM user_inbox WHERE user_id = ?1 AND network_id = ?2 AND kind = ?3 ORDER BY created_at, message_id",
|
|
109
|
+
userId, NET, KIND,
|
|
110
|
+
) as Array<{ message_id: string; from_session: string; title: string; content: string; meta_json: string }>;
|
|
111
|
+
|
|
112
|
+
beforeAll(async () => {
|
|
113
|
+
({ db } = await import("./db.js"));
|
|
114
|
+
const { addNetworkMember, createNetworkTokenForNode, register } = await import("./auth.js");
|
|
115
|
+
({ TASK_EXPIRED_NOTICE_KIND: KIND } = await import("./task-expiry-notice.js"));
|
|
116
|
+
const boss = register(`exp_boss_${stamp}`, PW);
|
|
117
|
+
expect(boss.ok).toBe(true);
|
|
118
|
+
bossToken = boss.token!; NET = boss.network_id!;
|
|
119
|
+
const bossId = boss.user!.user_id;
|
|
120
|
+
const uma = register(`exp_uma_${stamp}`, PW);
|
|
121
|
+
umaToken = uma.token!; umaId = uma.user!.user_id; umaName = uma.user!.username;
|
|
122
|
+
expect(addNetworkMember(NET, umaId, "member", bossId, { agentAccess: "all" }).ok).toBe(true);
|
|
123
|
+
for (const key of ["sender", "target", "other"] as const) {
|
|
124
|
+
const alias = `exp-${key}-${stamp}`;
|
|
125
|
+
const nodeId = `n_exp_${key}_${stamp}`;
|
|
126
|
+
const minted = createNetworkTokenForNode(bossId, NET, alias, nodeId);
|
|
127
|
+
expect(minted.ok).toBe(true);
|
|
128
|
+
nodes[key] = { alias, nodeId, token: minted.token! };
|
|
129
|
+
}
|
|
130
|
+
const mod: any = await import("./server.js");
|
|
131
|
+
patrol = mod.patrolExpiredTasks;
|
|
132
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
133
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
134
|
+
for (const n of Object.values(nodes)) {
|
|
135
|
+
const r = await tool(n.token, "report_status", { resume_id: `sdk-${n.nodeId}`, alias: n.alias, status: "idle", node_id: n.nodeId, network_id: NET });
|
|
136
|
+
expect(r.ok).toBe(true);
|
|
137
|
+
}
|
|
138
|
+
}, 30_000);
|
|
139
|
+
|
|
140
|
+
beforeEach(() => {
|
|
141
|
+
// 每条用例自己的数据:上一条留下的开着的任务不许算进「前面还有几个」,也不许被这一次巡检过期。
|
|
142
|
+
db.run("UPDATE tasks SET status = 'replied', completed_at = datetime('now') WHERE network_id = ?1 AND status IN ('created', 'delivered', 'acked', 'running')", [NET]);
|
|
143
|
+
db.run("DELETE FROM inbox WHERE network_id = ?1 AND id LIKE 'exp_%'", [NET]);
|
|
144
|
+
db.run("DELETE FROM user_inbox WHERE network_id = ?1 AND kind = ?2", [NET, KIND]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
afterAll(() => {
|
|
148
|
+
try { hub?.stop?.(true); } catch {}
|
|
149
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("#500 consumed before the deadline → not expired", () => {
|
|
153
|
+
test("consumed_at < expires_at keeps the task; a late consumed_at (after the deadline) does not", async () => {
|
|
154
|
+
const taken = await dispatch(nodes.sender.token, nodes.target.alias);
|
|
155
|
+
const late = await dispatch(nodes.sender.token, nodes.target.alias);
|
|
156
|
+
backdate(taken); backdate(late);
|
|
157
|
+
db.run("UPDATE tasks SET consumed_at = datetime('now', '-30 minutes') WHERE task_id = ?1", [taken]);
|
|
158
|
+
db.run("UPDATE tasks SET consumed_at = datetime('now') WHERE task_id = ?1", [late]);
|
|
159
|
+
patrol();
|
|
160
|
+
expect(taskRow(taken).status).toBe("delivered");
|
|
161
|
+
expect(taskRow(late).status).toBe("expired");
|
|
162
|
+
// 通知只说过期的那一条。
|
|
163
|
+
const n = agentNotices(nodes.sender.alias);
|
|
164
|
+
expect(n.length).toBe(1);
|
|
165
|
+
expect(n[0].content).toContain(late);
|
|
166
|
+
expect(n[0].content).not.toContain(taken);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("#500 sender is an agent node", () => {
|
|
171
|
+
test("exactly one terminal reply-row notice + new_reply push; get_inbox returns it", async () => {
|
|
172
|
+
const sub = await subscribe(`/events/${encodeURIComponent(nodes.sender.alias)}?network_id=${NET}`, nodes.sender.token);
|
|
173
|
+
try {
|
|
174
|
+
// 目标上有一条比它早、还开着的任务(别人派的,没到期)→ 「前面还有 1 个」。
|
|
175
|
+
const ahead = await dispatch(bossToken, nodes.target.alias);
|
|
176
|
+
db.run("UPDATE tasks SET created_at = datetime('now', '-90 minutes') WHERE task_id = ?1", [ahead]);
|
|
177
|
+
const id = await dispatch(nodes.sender.token, nodes.target.alias);
|
|
178
|
+
backdate(id);
|
|
179
|
+
patrol();
|
|
180
|
+
expect(taskRow(id).status).toBe("expired");
|
|
181
|
+
expect(taskRow(ahead).status).toBe("delivered");
|
|
182
|
+
|
|
183
|
+
const n = agentNotices(nodes.sender.alias);
|
|
184
|
+
expect(n.length).toBe(1);
|
|
185
|
+
expect(n[0].type).toBe("reply");
|
|
186
|
+
expect(n[0].from_session).toBe("hub");
|
|
187
|
+
expect(n[0].requires_response).toBe("none");
|
|
188
|
+
expect(n[0].in_reply_to).toBe(id);
|
|
189
|
+
for (const frag of ["[Hub] 任务已过期", nodes.target.alias, id, "等了 61 分钟", "期限 60 分钟", "还有 1 个", "retry_task", "不要原样重发", "do not blindly resend"]) {
|
|
190
|
+
expect(n[0].content).toContain(frag);
|
|
191
|
+
}
|
|
192
|
+
expect(JSON.parse(n[0].meta_json).task_expired).toEqual({ target: nodes.target.alias, task_ids: [id], queued_ahead: 1 });
|
|
193
|
+
|
|
194
|
+
await until(() => sub.events.some((e) => e.type === "new_reply" && e.message_id === n[0].id));
|
|
195
|
+
const push = sub.events.find((e) => e.type === "new_reply" && e.message_id === n[0].id);
|
|
196
|
+
expect(push).toMatchObject({ from: "hub", in_reply_to: id, status: "expired" });
|
|
197
|
+
|
|
198
|
+
const inbox = await tool(nodes.sender.token, "get_inbox", { alias: nodes.sender.alias, limit: 50 });
|
|
199
|
+
const got = JSON.stringify(inbox);
|
|
200
|
+
expect(got).toContain(n[0].id);
|
|
201
|
+
// 目标节点自己什么都没多收(过期的任务行已 acked,通知只给发送方)。
|
|
202
|
+
expect(agentNotices(nodes.target.alias).length).toBe(0);
|
|
203
|
+
|
|
204
|
+
// 再巡检一次:不重复发。
|
|
205
|
+
patrol();
|
|
206
|
+
expect(agentNotices(nodes.sender.alias).length).toBe(1);
|
|
207
|
+
} finally { sub.close(); }
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("#500 sender is a person (Dashboard / App)", () => {
|
|
212
|
+
test("user_inbox task_expired from the target node + desktop_message push; no agent inbox row", async () => {
|
|
213
|
+
const sub = await subscribe(`/events/users/me?network_id=${NET}`, umaToken);
|
|
214
|
+
try {
|
|
215
|
+
const id = await dispatch(umaToken, nodes.target.alias);
|
|
216
|
+
backdate(id);
|
|
217
|
+
patrol();
|
|
218
|
+
expect(taskRow(id).status).toBe("expired");
|
|
219
|
+
const n = userNotices(umaId);
|
|
220
|
+
expect(n.length).toBe(1);
|
|
221
|
+
expect(n[0].from_session).toBe(nodes.target.alias);
|
|
222
|
+
expect(n[0].title).toBe("任务已过期");
|
|
223
|
+
for (const frag of [nodes.target.alias, id, "等了 61 分钟", "retry_task", "不要原样重发"]) expect(n[0].content).toContain(frag);
|
|
224
|
+
await until(() => sub.events.some((e) => e.type === "desktop_message" && e.message_id === n[0].message_id));
|
|
225
|
+
expect(sub.events.find((e) => e.type === "desktop_message" && e.message_id === n[0].message_id)).toMatchObject({ kind: KIND, from: nodes.target.alias });
|
|
226
|
+
// 没有往「用户名」那个 alias 的 agent inbox 里塞东西。
|
|
227
|
+
expect(agentNotices(umaName).length).toBe(0);
|
|
228
|
+
} finally { sub.close(); }
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe("#500 sender is the scheduler", () => {
|
|
233
|
+
test("no notice to anyone; the task still expires", async () => {
|
|
234
|
+
const id = `sched_task_${stamp}`;
|
|
235
|
+
db.run(
|
|
236
|
+
`INSERT INTO tasks (task_id, from_name, to_name, to_node_id, priority, status, content, requires_response, created_at, delivered_at, expires_at, network_id, meta_json)
|
|
237
|
+
VALUES (?1, 'scheduler', ?2, ?3, 'normal', 'delivered', 'scheduled', 'reply', datetime('now', '-61 minutes'), datetime('now', '-61 minutes'), datetime('now', '-1 minutes'), ?4, ?5)`,
|
|
238
|
+
[id, nodes.target.alias, nodes.target.nodeId, NET, JSON.stringify({ auth_origin: "hub_scheduler" })],
|
|
239
|
+
);
|
|
240
|
+
patrol();
|
|
241
|
+
expect(taskRow(id).status).toBe("expired");
|
|
242
|
+
const anyAgent = db.get("SELECT COUNT(*) AS n FROM inbox WHERE network_id = ?1 AND id LIKE 'exp_%'", NET) as { n: number };
|
|
243
|
+
expect(Number(anyAgent.n)).toBe(0);
|
|
244
|
+
const anyUser = db.get("SELECT COUNT(*) AS n FROM user_inbox WHERE network_id = ?1 AND kind = ?2", NET, KIND) as { n: number };
|
|
245
|
+
expect(Number(anyUser.n)).toBe(0);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe("#500 child task expires", () => {
|
|
250
|
+
test("parent sender is told; parent stays open with its result untouched", async () => {
|
|
251
|
+
const parent = await dispatch(umaToken, nodes.sender.alias);
|
|
252
|
+
const child = await dispatch(nodes.sender.token, nodes.target.alias, { parent_task_id: parent });
|
|
253
|
+
backdate(child);
|
|
254
|
+
patrol();
|
|
255
|
+
expect(taskRow(child).status).toBe("expired");
|
|
256
|
+
expect(taskRow(parent)).toEqual({ status: "delivered", result: null });
|
|
257
|
+
// 子任务的发送方(父任务的执行者)照常收到过期通知。
|
|
258
|
+
expect(agentNotices(nodes.sender.alias).length).toBe(1);
|
|
259
|
+
// 父任务的发送方(人)收到一条「子任务已过期」,出现在与父任务执行者的会话里。
|
|
260
|
+
const n = userNotices(umaId);
|
|
261
|
+
expect(n.length).toBe(1);
|
|
262
|
+
expect(n[0].title).toBe("子任务已过期");
|
|
263
|
+
expect(n[0].from_session).toBe(nodes.sender.alias);
|
|
264
|
+
for (const frag of [parent, child, nodes.target.alias, "父任务没有被结束"]) expect(n[0].content).toContain(frag);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
describe("#500 one notice per (sender, target) per patrol pass", () => {
|
|
269
|
+
test("5 expiring to the same target → one summarized notice listing all 5", async () => {
|
|
270
|
+
const ids: string[] = [];
|
|
271
|
+
for (let i = 0; i < 5; i++) ids.push(await dispatch(nodes.sender.token, nodes.target.alias));
|
|
272
|
+
// 再来一条派给另一个目标:那是另一组,单独一条。
|
|
273
|
+
const elsewhere = await dispatch(nodes.sender.token, nodes.other.alias);
|
|
274
|
+
for (const id of [...ids, elsewhere]) backdate(id);
|
|
275
|
+
patrol();
|
|
276
|
+
for (const id of ids) expect(taskRow(id).status).toBe("expired");
|
|
277
|
+
const n = agentNotices(nodes.sender.alias);
|
|
278
|
+
expect(n.length).toBe(2);
|
|
279
|
+
const batch = n.find((x) => x.content.includes(nodes.target.alias))!;
|
|
280
|
+
expect(batch.content).toContain("[Hub] 5 个任务已过期");
|
|
281
|
+
for (const id of ids) expect(batch.content).toContain(id);
|
|
282
|
+
expect(batch.content).not.toContain(elsewhere);
|
|
283
|
+
expect(JSON.parse(batch.meta_json).task_expired.task_ids.sort()).toEqual([...ids].sort());
|
|
284
|
+
const single = n.find((x) => x.content.includes(nodes.other.alias))!;
|
|
285
|
+
expect(single.content).toContain(elsewhere);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// #500 —— 任务过期时告诉派活的人。
|
|
2
|
+
//
|
|
3
|
+
// 以前 TTL 巡检(server.ts patrolExpiredTasks)只把任务改成 expired、写一条 task_events,
|
|
4
|
+
// 派活的一方什么都收不到:14 天 109 条过期,0 条被告知;agent 收不到回音就原样重发,队列越排越长。
|
|
5
|
+
//
|
|
6
|
+
// 这里在巡检提交之后,按「派活的一方是谁」走它平时收回复的那条路:
|
|
7
|
+
// - agent 节点:inbox 写一行 type='reply'(requires_response='none')+ SSE new_reply。
|
|
8
|
+
// 与 send_reply 同形:agent-node(peer-reply-inbox.ts)和 claude-code channel(channel-inbox-wake.ts)
|
|
9
|
+
// 都把 new_reply 当门铃拉 inbox。agent-node 按 inbox-message-policy.ts 把 type=reply 当「终态结果」
|
|
10
|
+
// 交给模型、不回复(不会被当成新任务);claude-code channel 像别的回复一样注入会话。from_session = 'hub':自派(A→A)的任务不会被节点当成自己发的而跳过。
|
|
11
|
+
// - 人(Dashboard / App 的用户):agent-notice.ts(user_inbox + /events/users/me 的 desktop_message),
|
|
12
|
+
// from_session = 目标节点别名 —— 与 #462/#464 同一条路,出现在**与该节点的会话**里 + 未读 + 通知。
|
|
13
|
+
// - scheduler:不发。scheduled_task_runs 那一行已经记了 expired(syncScheduledRunForTask)。
|
|
14
|
+
// - hub / api / 认不出来的发送方:不发(没有可达的收件人)。
|
|
15
|
+
// 限流:同一次巡检里,同一个 (网络, 发送方, 目标) 只发**一条**,多条过期合并成一条摘要。
|
|
16
|
+
// 父任务:子任务过期时通知父任务的发送方一条(同样按父任务合并),**不**改父任务的状态 / result ——
|
|
17
|
+
// chainReplyToParent 会把父任务改成 replied/failed,这里刻意不走它:父任务的执行者可能还在干活。
|
|
18
|
+
// 全部在巡检事务提交之后做,每一步各自吞异常:通知失败不能让巡检失败,也不能回滚已经过期的任务。
|
|
19
|
+
|
|
20
|
+
import { db, uuidv4 } from "./db.js";
|
|
21
|
+
import { parseDbTimestampMs } from "./db-timestamp.js";
|
|
22
|
+
import { sendAgentNotice } from "./agent-notice.js";
|
|
23
|
+
import { pushEvent, pushNetworkObserverEvent } from "./push.js";
|
|
24
|
+
import { pendingInboxCount } from "./inbox-count.js";
|
|
25
|
+
|
|
26
|
+
export const TASK_EXPIRED_NOTICE_KIND = "task_expired";
|
|
27
|
+
|
|
28
|
+
/** 巡检刚刚结束为 expired 的一行(巡检 SELECT 出来的列)。 */
|
|
29
|
+
export type ExpiredTaskRow = {
|
|
30
|
+
task_id: string;
|
|
31
|
+
network_id: string | null;
|
|
32
|
+
from_name: string | null;
|
|
33
|
+
from_node_id: string | null;
|
|
34
|
+
to_name: string;
|
|
35
|
+
content: string | null;
|
|
36
|
+
created_at: string | null;
|
|
37
|
+
expires_at: string | null;
|
|
38
|
+
parent_task_id: string | null;
|
|
39
|
+
meta_json: string | null;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type ExpirySenderRoute =
|
|
43
|
+
| { kind: "agent"; alias: string }
|
|
44
|
+
| { kind: "user"; userId: string; username: string }
|
|
45
|
+
| { kind: "none"; reason: "scheduler" | "system" | "unknown" };
|
|
46
|
+
|
|
47
|
+
/** 一条发出去的通知(测试 / 日志用)。 */
|
|
48
|
+
export type ExpiryNoticeSent = {
|
|
49
|
+
route: "agent" | "user";
|
|
50
|
+
recipient: string;
|
|
51
|
+
target: string;
|
|
52
|
+
task_ids: string[];
|
|
53
|
+
message_id: string;
|
|
54
|
+
about: "task" | "child";
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const SYSTEM_SENDERS = new Set(["", "hub", "api"]);
|
|
58
|
+
const MAX_LISTED = 10;
|
|
59
|
+
|
|
60
|
+
function authOrigin(metaJson: string | null): string | null {
|
|
61
|
+
if (!metaJson) return null;
|
|
62
|
+
try {
|
|
63
|
+
const m = JSON.parse(metaJson);
|
|
64
|
+
return m && typeof m === "object" && typeof m.auth_origin === "string" ? m.auth_origin : null;
|
|
65
|
+
} catch { return null; }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isScheduledTask(taskId: string): boolean {
|
|
69
|
+
return !!db.get("SELECT 1 AS hit FROM scheduled_task_runs WHERE task_id = ?1", taskId);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function memberUser(username: string, networkId: string | null): { userId: string } | null {
|
|
73
|
+
if (!networkId) return null; // user_inbox 按网络存:没有网络就没有可达的人
|
|
74
|
+
const u = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", username);
|
|
75
|
+
if (!u) return null;
|
|
76
|
+
const m = db.get("SELECT 1 AS hit FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, u.user_id);
|
|
77
|
+
return m ? { userId: u.user_id } : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function agentSession(alias: string, networkId: string | null): { node_id: string | null } | null {
|
|
81
|
+
return networkId
|
|
82
|
+
? db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2 ORDER BY updated_at DESC LIMIT 1", alias, networkId)
|
|
83
|
+
: db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id IS NULL ORDER BY updated_at DESC LIMIT 1", alias);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 派活的一方是谁 → 走哪条路。
|
|
88
|
+
* 节点派的(auth_origin=node 或带 from_node_id)先按节点认;否则先按人认(Dashboard 用用户令牌派活,
|
|
89
|
+
* from_name 是用户名、from_node_id 为空)。两种都认不出 → 不发。
|
|
90
|
+
*/
|
|
91
|
+
export function classifyExpirySender(row: Pick<ExpiredTaskRow, "task_id" | "network_id" | "from_name" | "from_node_id" | "meta_json">): ExpirySenderRoute {
|
|
92
|
+
const from = (row.from_name ?? "").trim();
|
|
93
|
+
const origin = authOrigin(row.meta_json);
|
|
94
|
+
if (from === "scheduler" || origin === "hub_scheduler" || isScheduledTask(row.task_id)) return { kind: "none", reason: "scheduler" };
|
|
95
|
+
if (SYSTEM_SENDERS.has(from)) return { kind: "none", reason: "system" };
|
|
96
|
+
const nodeFirst = origin === "node" || (!!row.from_node_id && origin !== "user");
|
|
97
|
+
const asAgent = (): ExpirySenderRoute | null => (agentSession(from, row.network_id) ? { kind: "agent", alias: from } : null);
|
|
98
|
+
const asUser = (): ExpirySenderRoute | null => {
|
|
99
|
+
const u = memberUser(from, row.network_id);
|
|
100
|
+
return u ? { kind: "user", userId: u.userId, username: from } : null;
|
|
101
|
+
};
|
|
102
|
+
return (nodeFirst ? (asAgent() ?? asUser()) : (asUser() ?? asAgent())) ?? { kind: "none", reason: "unknown" };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 过期那一刻,目标节点上比它早、还没结束的任务数(正在跑的也算)。 */
|
|
106
|
+
export function openTasksAhead(target: string, networkId: string | null, createdAt: string | null): number | null {
|
|
107
|
+
if (!createdAt) return null;
|
|
108
|
+
try {
|
|
109
|
+
const row = networkId
|
|
110
|
+
? db.get<{ n: number }>(
|
|
111
|
+
`SELECT COUNT(*) AS n FROM tasks WHERE to_name = ?1 AND network_id = ?2
|
|
112
|
+
AND status IN ('created', 'delivered', 'acked', 'running') AND created_at < ?3`,
|
|
113
|
+
target, networkId, createdAt)
|
|
114
|
+
: db.get<{ n: number }>(
|
|
115
|
+
`SELECT COUNT(*) AS n FROM tasks WHERE to_name = ?1 AND network_id IS NULL
|
|
116
|
+
AND status IN ('created', 'delivered', 'acked', 'running') AND created_at < ?2`,
|
|
117
|
+
target, createdAt);
|
|
118
|
+
return Number(row?.n ?? 0);
|
|
119
|
+
} catch { return null; }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function minutesBetween(fromTs: string | null, toMs: number): number | null {
|
|
123
|
+
if (!fromTs) return null;
|
|
124
|
+
const ms = parseDbTimestampMs(fromTs);
|
|
125
|
+
return Number.isFinite(ms) ? Math.max(0, Math.round((toMs - ms) / 60_000)) : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function preview(content: string | null, max = 60): string {
|
|
129
|
+
const s = (content ?? "").replace(/\s+/g, " ").trim();
|
|
130
|
+
return s.length > max ? `${s.slice(0, max)}…` : s;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const EXPIRY_GUIDANCE_ZH = "建议:换一个空闲的节点重新派(已过期的任务不能 reassign_task),或稍后用 retry_task 让它在原节点重新排队;不要原样重发一条新任务 —— 重发只会让它的队列更长。";
|
|
134
|
+
export const EXPIRY_GUIDANCE_EN = "Consider sending it to another node or retry_task later; do not blindly resend.";
|
|
135
|
+
|
|
136
|
+
export type ExpiryNoticeInput = {
|
|
137
|
+
target: string;
|
|
138
|
+
tasks: Array<{ task_id: string; content: string | null; waited_min: number | null; ttl_min: number | null }>;
|
|
139
|
+
queued_ahead: number | null;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const fmtMin = (m: number | null) => (m === null ? "?" : String(m));
|
|
143
|
+
|
|
144
|
+
/** 给派活一方的正文。单条与多条两种形状;人和 agent 读同一份。 */
|
|
145
|
+
export function expiryNoticeText(n: ExpiryNoticeInput): { title: string; text: string } {
|
|
146
|
+
const count = n.tasks.length;
|
|
147
|
+
const ahead = n.queued_ahead === null ? "" : `过期时 ${n.target} 上还有 ${n.queued_ahead} 个比它早、没结束的任务。\n`;
|
|
148
|
+
const aheadEn = n.queued_ahead === null ? "" : `, ${n.queued_ahead} open task(s) ahead`;
|
|
149
|
+
if (count === 1) {
|
|
150
|
+
const t = n.tasks[0];
|
|
151
|
+
const ttl = t.ttl_min === null ? "" : `(期限 ${t.ttl_min} 分钟)`;
|
|
152
|
+
return {
|
|
153
|
+
title: "任务已过期",
|
|
154
|
+
text: `[Hub] 任务已过期:你派给 ${n.target} 的任务 ${t.task_id} 等了 ${fmtMin(t.waited_min)} 分钟仍没被取走${ttl},Hub 已把它结束为 expired,${n.target} 不会再执行它。\n`
|
|
155
|
+
+ ahead
|
|
156
|
+
+ `内容:${preview(t.content)}\n`
|
|
157
|
+
+ `${EXPIRY_GUIDANCE_ZH}\n`
|
|
158
|
+
+ `Task ${t.task_id} to ${n.target} expired unconsumed after ${fmtMin(t.waited_min)} min${aheadEn}. ${EXPIRY_GUIDANCE_EN}`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const waited = n.tasks.map((t) => t.waited_min).filter((m): m is number => m !== null);
|
|
162
|
+
const longest = waited.length ? Math.max(...waited) : null;
|
|
163
|
+
const lines = n.tasks.slice(0, MAX_LISTED).map((t) => `- ${t.task_id} 等了 ${fmtMin(t.waited_min)} 分钟:${preview(t.content, 40)}`);
|
|
164
|
+
if (count > MAX_LISTED) lines.push(`- …另外 ${count - MAX_LISTED} 个`);
|
|
165
|
+
return {
|
|
166
|
+
title: `${count} 个任务已过期`,
|
|
167
|
+
text: `[Hub] ${count} 个任务已过期:你派给 ${n.target} 的 ${count} 个任务都没被取走(最长等了 ${fmtMin(longest)} 分钟),Hub 已把它们结束为 expired,${n.target} 不会再执行。\n`
|
|
168
|
+
+ ahead
|
|
169
|
+
+ `${lines.join("\n")}\n`
|
|
170
|
+
+ `${EXPIRY_GUIDANCE_ZH}\n`
|
|
171
|
+
+ `${count} tasks to ${n.target} expired unconsumed (longest wait ${fmtMin(longest)} min${aheadEn}). ${EXPIRY_GUIDANCE_EN}`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** 父任务发送方收到的正文:子任务过期了,父任务**没有**被结束。 */
|
|
176
|
+
export function childExpiryNoticeText(p: { parent_task_id: string; parent_target: string; child_target: string; child_task_ids: string[] }): { title: string; text: string } {
|
|
177
|
+
const n = p.child_task_ids.length;
|
|
178
|
+
return {
|
|
179
|
+
title: "子任务已过期",
|
|
180
|
+
text: `[Hub] 子任务已过期:你派给 ${p.parent_target} 的任务 ${p.parent_task_id} 拆出的 ${n} 个子任务(派给 ${p.child_target}:${p.child_task_ids.join(", ")})没被取走,已结束为 expired。`
|
|
181
|
+
+ `父任务没有被结束,仍由 ${p.parent_target} 处理;它也收到了过期通知。\n`
|
|
182
|
+
+ `Sub-task(s) of ${p.parent_task_id} sent by ${p.parent_target} to ${p.child_target} expired unconsumed; the parent task is still open.`,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** 审计:task_events 记一笔「通知过谁」。不用 logTaskEvent:它按 to_status 派生 event_type,
|
|
187
|
+
* 写 expired 会多出一条 task.expired,让按事件数过期的统计翻倍。 */
|
|
188
|
+
function noteEvent(taskId: string, status: string, detail: string): void {
|
|
189
|
+
try {
|
|
190
|
+
db.run(
|
|
191
|
+
`INSERT INTO task_events (task_id, from_status, to_status, event_type, actor, detail, network_id)
|
|
192
|
+
VALUES (?1, ?2, ?2, 'task.expiry_notice', 'patrol', ?3, (SELECT network_id FROM tasks WHERE task_id = ?1))`,
|
|
193
|
+
[taskId, status, detail],
|
|
194
|
+
);
|
|
195
|
+
} catch {}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function deliverToAgent(alias: string, networkId: string | null, inReplyTo: string, text: string, meta: Record<string, unknown>): string {
|
|
199
|
+
const id = `exp_${uuidv4().replace(/-/g, "").slice(0, 20)}`;
|
|
200
|
+
const nodeId = agentSession(alias, networkId)?.node_id ?? null;
|
|
201
|
+
db.run(
|
|
202
|
+
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, in_reply_to, requires_response, network_id, meta_json)
|
|
203
|
+
VALUES (?1, ?2, ?3, 'reply', 'normal', ?4, 'hub', ?5, 'none', ?6, ?7)`,
|
|
204
|
+
[id, alias, nodeId, text, inReplyTo, networkId, JSON.stringify(meta)],
|
|
205
|
+
);
|
|
206
|
+
pushEvent(alias, { type: "new_reply", inbox_count: pendingInboxCount(alias, networkId), from: "hub", message_id: id, in_reply_to: inReplyTo, status: "expired" }, networkId);
|
|
207
|
+
return id;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function deliver(
|
|
211
|
+
route: Extract<ExpirySenderRoute, { kind: "agent" | "user" }>,
|
|
212
|
+
networkId: string | null,
|
|
213
|
+
fromAlias: string,
|
|
214
|
+
inReplyTo: string,
|
|
215
|
+
notice: { title: string; text: string },
|
|
216
|
+
meta: Record<string, unknown>,
|
|
217
|
+
): string | null {
|
|
218
|
+
if (route.kind === "agent") return deliverToAgent(route.alias, networkId, inReplyTo, notice.text, meta);
|
|
219
|
+
if (!networkId) return null;
|
|
220
|
+
return sendAgentNotice({
|
|
221
|
+
networkId, userId: route.userId, fromAlias, kind: TASK_EXPIRED_NOTICE_KIND,
|
|
222
|
+
title: notice.title, text: notice.text, severity: "warning", meta, idPrefix: "dm_expired_",
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* 巡检提交之后调用。返回发出去的通知(测试用)。任何一组失败只记日志,不影响别的组。
|
|
228
|
+
*/
|
|
229
|
+
export function notifyExpiredTasks(rows: ExpiredTaskRow[], nowMs = Date.now()): ExpiryNoticeSent[] {
|
|
230
|
+
const sent: ExpiryNoticeSent[] = [];
|
|
231
|
+
if (rows.length === 0) return sent;
|
|
232
|
+
|
|
233
|
+
// 网络观察者(Dashboard 任务页):与 send_reply 同一个摘要事件,只带路由元数据,让任务行刷新成 expired。
|
|
234
|
+
for (const r of rows) {
|
|
235
|
+
try { pushNetworkObserverEvent(r.network_id, { type: "new_reply", task_id: r.task_id, message_id: null, from: "hub", to: r.from_name ?? null, status: "expired" }); } catch {}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 1. 给派活的一方:按 (网络, 发送方, 目标) 合并。
|
|
239
|
+
const groups = new Map<string, ExpiredTaskRow[]>();
|
|
240
|
+
for (const r of rows) {
|
|
241
|
+
const key = JSON.stringify([r.network_id ?? null, r.from_name ?? "", r.to_name]);
|
|
242
|
+
const g = groups.get(key);
|
|
243
|
+
if (g) g.push(r); else groups.set(key, [r]);
|
|
244
|
+
}
|
|
245
|
+
for (const group of groups.values()) {
|
|
246
|
+
try {
|
|
247
|
+
group.sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)));
|
|
248
|
+
const head = group[0];
|
|
249
|
+
const route = classifyExpirySender(head);
|
|
250
|
+
if (route.kind === "none") continue;
|
|
251
|
+
const newest = group[group.length - 1];
|
|
252
|
+
const input: ExpiryNoticeInput = {
|
|
253
|
+
target: head.to_name,
|
|
254
|
+
tasks: group.map((t) => ({
|
|
255
|
+
task_id: t.task_id,
|
|
256
|
+
content: t.content,
|
|
257
|
+
waited_min: minutesBetween(t.created_at, nowMs),
|
|
258
|
+
ttl_min: t.created_at && t.expires_at
|
|
259
|
+
? Math.round((parseDbTimestampMs(t.expires_at) - parseDbTimestampMs(t.created_at)) / 60_000)
|
|
260
|
+
: null,
|
|
261
|
+
})),
|
|
262
|
+
queued_ahead: openTasksAhead(head.to_name, head.network_id, newest.created_at),
|
|
263
|
+
};
|
|
264
|
+
const notice = expiryNoticeText(input);
|
|
265
|
+
const taskIds = group.map((t) => t.task_id);
|
|
266
|
+
const meta = { task_expired: { target: head.to_name, task_ids: taskIds, queued_ahead: input.queued_ahead } };
|
|
267
|
+
const id = deliver(route, head.network_id, head.to_name, newest.task_id, notice, meta);
|
|
268
|
+
if (id) {
|
|
269
|
+
sent.push({ route: route.kind, recipient: route.kind === "agent" ? route.alias : route.userId, target: head.to_name, task_ids: taskIds, message_id: id, about: "task" });
|
|
270
|
+
for (const t of group) noteEvent(t.task_id, "expired", `notified ${route.kind} ${route.kind === "agent" ? route.alias : route.username}`);
|
|
271
|
+
}
|
|
272
|
+
} catch (e: any) {
|
|
273
|
+
console.error(`[patrol] expiry notice failed: ${e?.message || e}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// 2. 父任务:按父任务合并,只通知,不改父任务的状态 / result。跨网络的父任务不碰。
|
|
278
|
+
const byParent = new Map<string, ExpiredTaskRow[]>();
|
|
279
|
+
for (const r of rows) {
|
|
280
|
+
if (!r.parent_task_id) continue;
|
|
281
|
+
const g = byParent.get(r.parent_task_id);
|
|
282
|
+
if (g) g.push(r); else byParent.set(r.parent_task_id, [r]);
|
|
283
|
+
}
|
|
284
|
+
for (const [parentId, children] of byParent) {
|
|
285
|
+
try {
|
|
286
|
+
const parent = db.get<{ task_id: string; from_name: string | null; from_node_id: string | null; to_name: string; status: string; network_id: string | null; meta_json: string | null }>(
|
|
287
|
+
"SELECT task_id, from_name, from_node_id, to_name, status, network_id, meta_json FROM tasks WHERE task_id = ?1",
|
|
288
|
+
parentId,
|
|
289
|
+
);
|
|
290
|
+
if (!parent) continue;
|
|
291
|
+
const childNet = children[0].network_id ?? null;
|
|
292
|
+
if ((parent.network_id ?? null) !== childNet) continue;
|
|
293
|
+
const childIds = children.map((c) => c.task_id);
|
|
294
|
+
const childTargets = [...new Set(children.map((c) => c.to_name))].join(", ");
|
|
295
|
+
noteEvent(parent.task_id, parent.status, `child expired: ${childIds.join(",")} → ${childTargets}`);
|
|
296
|
+
const route = classifyExpirySender(parent);
|
|
297
|
+
if (route.kind === "none") continue;
|
|
298
|
+
const notice = childExpiryNoticeText({ parent_task_id: parent.task_id, parent_target: parent.to_name, child_target: childTargets, child_task_ids: childIds });
|
|
299
|
+
const meta = { task_expired: { parent_task_id: parent.task_id, target: childTargets, task_ids: childIds } };
|
|
300
|
+
const id = deliver(route, parent.network_id, parent.to_name, parent.task_id, notice, meta);
|
|
301
|
+
if (id) sent.push({ route: route.kind, recipient: route.kind === "agent" ? route.alias : route.userId, target: childTargets, task_ids: childIds, message_id: id, about: "child" });
|
|
302
|
+
} catch (e: any) {
|
|
303
|
+
console.error(`[patrol] child-expiry notice failed parent=${parentId}: ${e?.message || e}`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return sent;
|
|
307
|
+
}
|
package/src/tools.ts
CHANGED
|
@@ -5977,7 +5977,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5977
5977
|
limit: z.number().int().min(1).max(1000).optional().describe(`page size, default ${REQ_LIST_MCP_DEFAULT_LIMIT}`),
|
|
5978
5978
|
cursor: z.string().max(600).optional().describe("next_cursor from the previous page"),
|
|
5979
5979
|
view: z.enum(["full", "summary"]).optional().describe("default summary (no description / checklist bodies; has_description, checklist_count instead); full = everything"),
|
|
5980
|
-
changes: z.boolean().optional().describe("with updated_since: only tasks changed since then (archived included) + deleted ids + server_time"),
|
|
5980
|
+
changes: z.boolean().optional().describe("with updated_since: only tasks changed since then (archived included; with include_last_event also ones with a newer event, e.g. a comment) + deleted ids + server_time"),
|
|
5981
5981
|
include_last_event: z.boolean().optional().describe("default false; true adds last_event per row (newest event incl. comments: type, field, actor, at, summary; null if none), ~150 B/row"),
|
|
5982
5982
|
};
|
|
5983
5983
|
const reqListKeys = Object.keys(reqListShape);
|