@sleep2agi/commhub-server 0.9.0-preview.30 → 0.9.0-preview.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.30",
3
+ "version": "0.9.0-preview.32",
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",
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { parseDbTimestampMs } from "./db-timestamp.js";
3
+
4
+ const NAIVE = "2026-08-27 11:23:54";
5
+ const EXPECTED = Date.UTC(2026, 7, 27, 11, 23, 54);
6
+
7
+ describe("parseDbTimestampMs", () => {
8
+ // ⚠ 这条在 **TZ=UTC 的环境**下没有分辨力:裸 Date.parse 在 UTC 下恰好正确。
9
+ // CI runner 与容器默认就是 UTC(本仓 Docker 套件亦然),所以缺陷在常规跑法里
10
+ // 恒绿。(更正:Bun 是尊重 TZ 的,不存在"bun test 固定 UTC"——通信评审牛 #1279
11
+ // 独审③ 独立复跑证伪了我最初的说法。)真正稳定见红的门是下面那条强制子进程。
12
+ it("treats a naive SQLite timestamp as UTC", () => {
13
+ expect(parseDbTimestampMs(NAIVE)).toBe(EXPECTED);
14
+ });
15
+
16
+ // 分辨力关键:宿主是 UTC 时,裸 Date.parse 与正确写法结果相同,断言恒绿。
17
+ // 强制子进程 TZ=Asia/Shanghai,让缺陷在任何 CI 时区下都显形。
18
+ it("is unaffected by a non-UTC host timezone (child process TZ=Asia/Shanghai)", () => {
19
+ const r = Bun.spawnSync({
20
+ cmd: ["bun", "-e",
21
+ `const {parseDbTimestampMs} = await import("${import.meta.dir}/db-timestamp.ts");` +
22
+ `console.log(parseDbTimestampMs("${NAIVE}"));`],
23
+ env: { ...process.env, TZ: "Asia/Shanghai" },
24
+ });
25
+ expect(Number(r.stdout.toString().trim())).toBe(EXPECTED);
26
+ });
27
+
28
+ it("keeps explicit-offset timestamps intact", () => {
29
+ expect(parseDbTimestampMs("2026-08-27T11:23:54Z")).toBe(EXPECTED);
30
+ expect(parseDbTimestampMs("2026-08-27T19:23:54+08:00")).toBe(EXPECTED);
31
+ });
32
+
33
+ it("an online window of 5min holds for a heartbeat written 1s ago", () => {
34
+ const now = Date.now();
35
+ const iso = new Date(now - 1000).toISOString().slice(0, 19).replace("T", " ");
36
+ expect(now - parseDbTimestampMs(iso)).toBeLessThanOrEqual(5 * 60_000);
37
+ });
38
+ });
@@ -0,0 +1,15 @@
1
+ /**
2
+ * SQLite 里的时间戳列(sessions.last_seen_at、tasks.created_at 等)存的是
3
+ * **无时区标记的 UTC 串**(`YYYY-MM-DD HH:MM:SS`,由 SQLite `datetime('now')` 写入)。
4
+ *
5
+ * 裸 `Date.parse("2026-08-27 11:23:54")` 会按**宿主机本地时区**解释这个串。
6
+ * 生产机 TZ=CST+0800 时,它比真实 UTC 时刻早 8 小时——所以 `now - parsed`
7
+ * 恒为 +480 分钟,任何「最近 N 秒内算在线」的判据都会恒判离线。
8
+ * 在 TZ=UTC 的机器上两种写法结果相同,缺陷不会显形。
9
+ */
10
+ export function parseDbTimestampMs(value: string): number {
11
+ const s = String(value).trim();
12
+ // 已带时区标记(ISO 的 Z / ±HH:MM)就原样交给 Date
13
+ if (/[Zz]$|[+-]\d{2}:?\d{2}$/.test(s)) return Date.parse(s);
14
+ return Date.parse(s.replace(" ", "T") + "Z");
15
+ }
@@ -0,0 +1,140 @@
1
+ // f015d9d6 症状①:tasks.status 被置为 'delivered',而 SSE 推送根本没发出。
2
+ //
3
+ // send_task 无条件以字面量 'delivered' 插入 tasks 行,推送却另外被
4
+ // `if (target.state === "online")` 挡着(tools.ts:1432)。同一段代码里给
5
+ // network observer 的事件把同一件事标成 "queued" —— 也就是说代码**知道**
6
+ // 它没投出去,却仍然把 delivered 写进了 tasks.status。
7
+ //
8
+ // 而 "online" 的判据是 resolveDeliveryTarget 里的
9
+ // stale = now - (last_seen_at || updated_at) > 5 分钟
10
+ // 且 last_seen_at 只在 report_status 时刷新(tools.ts:674/702)。
11
+ // 全网「停发心跳/状态类消息」的省额度规则之下,一个安静的 MCP 会话必然变 stale。
12
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
13
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
14
+ import { db } from "./db.js";
15
+ import { registerTools } from "./tools.js";
16
+ import { __resetSSEClientsForTest, createSSEStream } from "./push.js";
17
+
18
+ const NET = "net_delivered_push_f015";
19
+ const USER = "user_delivered_push_f015";
20
+ const SENDER = "sender-f015b";
21
+ const TARGET = "quiet-mcp-f015b";
22
+
23
+ type ToolHandler = (args: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
24
+
25
+ function cleanup() {
26
+ __resetSSEClientsForTest();
27
+ for (const t of ["tasks", "inbox", "task_events", "sessions", "api_tokens", "nodes", "rename_txn"]) {
28
+ try { db.run(`DELETE FROM ${t} WHERE network_id = ?1`, [NET]); } catch {}
29
+ }
30
+ try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET]); } catch {}
31
+ try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
32
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
33
+ }
34
+
35
+ /** minutesAgo 控制目标会话的 last_seen_at —— 这是本文件唯一要动的变量。 */
36
+ function seed(minutesAgo: number) {
37
+ db.run("INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?1, 'x', 'admin', datetime('now'))", [USER]);
38
+ db.run("INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))", [NET, USER]);
39
+ db.run("INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))", [USER, NET]);
40
+ for (const alias of [SENDER, TARGET]) {
41
+ db.run(
42
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, 'network')",
43
+ [`token-${alias}`, `hash-${alias}`, USER, NET, `node:${alias}`],
44
+ );
45
+ }
46
+ db.run(
47
+ "INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at) VALUES (?1, ?1, 'idle', NULL, ?2, datetime('now'), datetime('now', ?3))",
48
+ [SENDER, NET, "-1 minutes"],
49
+ );
50
+ // 🔴 目标:MCP 挂载的 claude-code 会话形状(node_id NULL),
51
+ // status 是 'idle'(不是 offline),只是安静了 minutesAgo 分钟。
52
+ db.run(
53
+ "INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at) VALUES (?1, ?2, 'idle', NULL, ?3, datetime('now', ?4), datetime('now', ?4))",
54
+ [`cc-n_${TARGET}`, TARGET, NET, `-${minutesAgo} minutes`],
55
+ );
56
+ }
57
+
58
+ function toolsFor(alias: string): Record<string, ToolHandler> {
59
+ const server = new McpServer({ name: "delivered-push-f015", version: "0" }) as any;
60
+ const tools: Record<string, ToolHandler> = {};
61
+ const original = server.tool.bind(server);
62
+ server.tool = (name: string, description: string, schema: any, handler: ToolHandler) => {
63
+ tools[name] = handler; return original(name, description, schema, handler);
64
+ };
65
+ registerTools(server, undefined, NET, USER, alias, true, `token-${alias}`);
66
+ return tools;
67
+ }
68
+
69
+ /** 从订阅者那一侧真正读出字节 —— 地面真相,不是连接计数。 */
70
+ async function readUntil(reader: ReadableStreamDefaultReader<Uint8Array>, needle: string, ms = 1500): Promise<string | null> {
71
+ const dec = new TextDecoder();
72
+ let buf = "";
73
+ const deadline = Date.now() + ms;
74
+ while (Date.now() < deadline) {
75
+ const chunk = await Promise.race([
76
+ reader.read(),
77
+ new Promise<{ done: true; value: undefined }>((r) => setTimeout(() => r({ done: true, value: undefined }), 200)),
78
+ ]);
79
+ if (chunk.value) buf += dec.decode(chunk.value, { stream: true });
80
+ if (buf.includes(needle)) return buf;
81
+ }
82
+ return null;
83
+ }
84
+
85
+ let sendSeq = 0;
86
+ /** minutesAgo = 目标安静了多久;withSubscriber = 是否真的有人连着。 */
87
+ async function send(minutesAgo: number, withSubscriber = true) {
88
+ cleanup(); seed(minutesAgo);
89
+ let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
90
+ if (withSubscriber) {
91
+ const res = createSSEStream(TARGET, NET);
92
+ reader = res.body!.getReader();
93
+ }
94
+ const r = await toolsFor(SENDER).send_task({ alias: TARGET, task: `ping-${++sendSeq}`, priority: "normal" });
95
+ const out = JSON.parse(r.content[0].text);
96
+ const row = db.get<{ status: string }>("SELECT status FROM tasks WHERE to_name = ?1 AND network_id = ?2", TARGET, NET);
97
+ const received = reader ? await readUntil(reader, "new_task") : null;
98
+ return { out, taskStatus: row?.status ?? null, received };
99
+ }
100
+
101
+ describe("f015d9d6 症状① — delivered 置位与实际推送不同步", () => {
102
+ beforeEach(() => cleanup());
103
+ afterAll(() => cleanup());
104
+
105
+ test("对照:目标 1 分钟前还活跃 ⇒ 订阅者收到 new_task", async () => {
106
+ const { taskStatus, received } = await send(1);
107
+ expect(taskStatus).toBe("delivered");
108
+ expect(received).toContain("new_task");
109
+ });
110
+
111
+ test("🔴 核心:目标安静 10 分钟但订阅者连着 ⇒ 仍然收得到(修复前这里收到 null)", async () => {
112
+ const { taskStatus, received } = await send(10);
113
+ expect(received).toContain("new_task");
114
+ expect(taskStatus).toBe("delivered"); // 现在这个 delivered 是真话了
115
+ });
116
+
117
+ test("🔴 可达性判据必须来自订阅者注册表,不是 last_seen_at 的时间戳猜测", async () => {
118
+ // 安静 10 分钟 = 时间戳判据会说 offline;而实际上有人在听。
119
+ const quiet = await send(10);
120
+ expect(quiet.received).toContain("new_task");
121
+ // 真没人听的时候,pushEvent 本来就是 no-op,不需要额外的闸来保护。
122
+ const nobody = await send(10, false);
123
+ expect(nobody.received).toBeNull();
124
+ });
125
+
126
+ test("真的没有订阅者时,tasks 行仍写 delivered —— 本 PR 刻意不改,记录在案", async () => {
127
+ const { taskStatus, received } = await send(10, false);
128
+ expect(received).toBeNull();
129
+ // 这一条钉的是**当前行为**,不是我认为对的行为:没人收到,行仍自称 delivered。
130
+ expect(taskStatus).toBe("delivered");
131
+ });
132
+
133
+ test("活性判据的输入 last_seen_at 只由 report_status 刷新 —— 发送方不刷新它", async () => {
134
+ cleanup(); seed(4);
135
+ const before = db.get<{ last_seen_at: string }>("SELECT last_seen_at FROM sessions WHERE alias = ?1 AND network_id = ?2", TARGET, NET);
136
+ await toolsFor(SENDER).send_task({ alias: TARGET, task: `ping-${++sendSeq}`, priority: "normal" });
137
+ const after = db.get<{ last_seen_at: string }>("SELECT last_seen_at FROM sessions WHERE alias = ?1 AND network_id = ?2", TARGET, NET);
138
+ expect(after?.last_seen_at).toBe(before!.last_seen_at);
139
+ });
140
+ });
@@ -0,0 +1,25 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ type PackageManifest = { version: string };
6
+ type PackageLock = {
7
+ version: string;
8
+ packages: { "": { version: string } };
9
+ };
10
+
11
+ const readJson = <T>(name: string): T =>
12
+ JSON.parse(readFileSync(join(import.meta.dir, "..", name), "utf8")) as T;
13
+
14
+ describe("server package version consistency", () => {
15
+ const manifest = readJson<PackageManifest>("package.json");
16
+ const lock = readJson<PackageLock>("package-lock.json");
17
+
18
+ test("package-lock top-level version matches package.json", () => {
19
+ expect(lock.version).toBe(manifest.version);
20
+ });
21
+
22
+ test("package-lock root package version matches package.json", () => {
23
+ expect(lock.packages[""].version).toBe(manifest.version);
24
+ });
25
+ });
package/src/push.ts CHANGED
@@ -382,6 +382,17 @@ eventBus.on("rename-committed", (event: RenameCommittedEvent) => {
382
382
  });
383
383
 
384
384
  /** 推送事件给指定 session 的所有 SSE 连接 */
385
+ /** 这个 alias 上此刻是否真的有活着的 SSE 订阅者。
386
+ *
387
+ * 这是投递可达性的**实测**,区别于 resolveDeliveryTarget 里那个基于
388
+ * last_seen_at 的**猜测**。一个安静但连着的会话(例如遵守「停发心跳」
389
+ * 规则的 MCP 会话)在时间戳判据下是 offline,在这里是 true。 */
390
+ export function hasSubscribers(sessionName: string, networkId?: string | null): boolean {
391
+ const arr = clients.get(clientKey(sessionName, networkId));
392
+ if (!arr) return false;
393
+ return arr.some((c) => !c.closed);
394
+ }
395
+
385
396
  export function pushEvent(sessionName: string, event: Record<string, unknown>, networkId?: string | null): void {
386
397
  const key = clientKey(sessionName, networkId ?? (event.network_id as string | null | undefined));
387
398
  const arr = clients.get(key);
@@ -0,0 +1,96 @@
1
+ // f015d9d6 根因隔离:admin(dashboard/人类) 发给「无 node_id 的 MCP 会话」的任务,
2
+ // 其回执被 reply_target_mismatch 拒绝。
3
+ //
4
+ // 上报时给出的假设是「校验依赖投递绑定 ⇒ 任务还没真正 deliver 进会话时回执会被拒」。
5
+ // 本文件把两个变量分开量,一次只动一个:
6
+ // 变量 1 = 任务是否已投递(status created vs delivered,有无 SSE 订阅者)
7
+ // 变量 2 = 调用方是否显式传 alias
8
+ // 如果假设成立,变量 1 应当决定结果;如果 alias 推导才是决定因素,变量 2 才会。
9
+ import { beforeEach, afterAll, describe, expect, test } from "bun:test";
10
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
+ import { db } from "./db.js";
12
+ import { registerTools } from "./tools.js";
13
+ import { __resetSSEClientsForTest, createSSEStream } from "./push.js";
14
+
15
+ const NET = "net_admin_reply_f015";
16
+ const USER = "user_admin_reply_f015";
17
+ const ADMIN = "admin-f015"; // 人类/dashboard 发起方,无 node
18
+ const AGENT = "agent-mcp-f015"; // MCP 会话:sessions 行 node_id = NULL
19
+ const AGENT_NODE = "node-agent-f015"; // token 绑定的 node(会话本身仍无 node_id)
20
+
21
+ type ToolHandler = (args: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
22
+
23
+ function cleanup() {
24
+ __resetSSEClientsForTest();
25
+ for (const t of ["tasks", "inbox", "task_events", "sessions", "api_tokens", "nodes", "rename_txn"]) {
26
+ try { db.run(`DELETE FROM ${t} WHERE network_id = ?1`, [NET]); } catch {}
27
+ }
28
+ try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET]); } catch {}
29
+ try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
30
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
31
+ }
32
+
33
+ function seed() {
34
+ db.run("INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?1, 'x', 'admin', datetime('now'))", [USER]);
35
+ db.run("INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))", [NET, USER]);
36
+ db.run("INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))", [USER, NET]);
37
+ db.run(
38
+ "INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, config_snapshot, created_at, updated_at, lifecycle_state) VALUES (?1, ?2, ?2, ?3, 'host', '{}', datetime('now'), datetime('now'), 'active')",
39
+ [AGENT_NODE, AGENT, NET],
40
+ );
41
+ // 🔴 关键夹具属性:会话行 node_id 为 NULL —— 这正是 MCP 挂载的 claude-code 会话的形状
42
+ db.run(
43
+ "INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at) VALUES (?1, ?2, 'idle', NULL, ?3, datetime('now'), datetime('now'))",
44
+ [`cc-n_${AGENT}`, AGENT, NET],
45
+ );
46
+ db.run(
47
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, bound_node_id) VALUES (?1, ?2, ?3, ?4, ?5, 'network', ?6)",
48
+ [`token-${AGENT}`, `hash-${AGENT}`, USER, NET, `node:${AGENT}`, AGENT_NODE],
49
+ );
50
+ }
51
+
52
+ function toolsFor(alias: string): Record<string, ToolHandler> {
53
+ const server = new McpServer({ name: "admin-reply-f015", version: "0" }) as any;
54
+ const tools: Record<string, ToolHandler> = {};
55
+ const original = server.tool.bind(server);
56
+ server.tool = (name: string, description: string, schema: any, handler: ToolHandler) => {
57
+ tools[name] = handler; return original(name, description, schema, handler);
58
+ };
59
+ registerTools(server, undefined, NET, USER, alias, true, `token-${alias}`);
60
+ return tools;
61
+ }
62
+
63
+ function parse(res: { content: Array<{ text: string }> }) { return JSON.parse(res.content[0].text); }
64
+
65
+ /** 造一条 admin(人类) → AGENT 的任务;delivered 控制「是否已投递」这一个变量。 */
66
+ function seedAdminTask(taskId: string, delivered: boolean) {
67
+ db.run(
68
+ `INSERT INTO tasks (task_id, from_name, from_node_id, to_name, to_node_id, content, status, priority, network_id, created_at)
69
+ VALUES (?1, ?2, NULL, ?3, NULL, 'ping', ?4, 'normal', ?5, datetime('now'))`,
70
+ [taskId, ADMIN, AGENT, delivered ? "delivered" : "created", NET],
71
+ );
72
+ }
73
+
74
+ describe("f015d9d6 — admin→无 node_id 的 MCP 会话,回执绑定", () => {
75
+ beforeEach(() => { cleanup(); seed(); });
76
+ afterAll(() => cleanup());
77
+
78
+ test("变量1:任务【未投递】+ 省略 alias(走 #1085 推导)⇒ 回执应当成功", async () => {
79
+ seedAdminTask("t-undelivered", false);
80
+ const r = parse(await toolsFor(AGENT).send_reply({ in_reply_to: "t-undelivered", text: "done", status: "completed" }));
81
+ expect(r).toEqual(expect.objectContaining({ ok: true }));
82
+ });
83
+
84
+ test("变量1 对照:任务【已投递且有 SSE 订阅者】+ 省略 alias ⇒ 同样成功", async () => {
85
+ seedAdminTask("t-delivered", true);
86
+ createSSEStream(AGENT, NET);
87
+ const r = parse(await toolsFor(AGENT).send_reply({ in_reply_to: "t-delivered", text: "done", status: "completed" }));
88
+ expect(r).toEqual(expect.objectContaining({ ok: true }));
89
+ });
90
+
91
+ test("变量2:显式传一个不等于任务发起方的 alias ⇒ reply_target_mismatch", async () => {
92
+ seedAdminTask("t-wrong-alias", true);
93
+ const r = parse(await toolsFor(AGENT).send_reply({ alias: "someone-else", in_reply_to: "t-wrong-alias", text: "done", status: "completed" }));
94
+ expect(r).toEqual(expect.objectContaining({ ok: false, error: "reply_target_mismatch" }));
95
+ });
96
+ });
@@ -0,0 +1,131 @@
1
+ // #1085 — send_reply must not require `alias` when `in_reply_to` is set.
2
+ //
3
+ // Symptom (deployed SDK-runtime nodes): `mcp__commhub__send_reply(task_id,
4
+ // text)` → `-32602 Invalid input: expected string, received undefined at
5
+ // alias`. That is the HUB's Zod schema rejecting a required `alias` the
6
+ // client never sent — the request dies before the handler runs, so the
7
+ // original task is never terminalized and its inbox row stays unacked
8
+ // (pending inflation).
9
+ //
10
+ // Fix (two parts, both in tools.ts):
11
+ // 1. `replyToolSchema.alias` → optional (stops the MCP-layer Zod -32602).
12
+ // 2. `handleReply` derives the target from the in_reply_to task's
13
+ // `from_name` when alias is omitted — the target was always knowable
14
+ // from the task the hub already looks up, so the param was redundant.
15
+ //
16
+ // This suite drives the registered handler directly (MCP Zod layer is
17
+ // exercised separately by the framework), so it pins part (2): omit alias
18
+ // → derive → reply lands on the original sender; omit both → clean error.
19
+ //
20
+ // 改坏报红 gate: revert the derive block (require alias again) and
21
+ // "alias omitted → derives to original sender" flips to a resolve failure.
22
+
23
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
24
+ import { db } from "./db.js";
25
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
26
+ import { registerTools } from "./tools.js";
27
+
28
+ const NET_ID = "net_1085";
29
+ const USER_ID = "u_1085";
30
+ const NODE_ID = "node_1085";
31
+ const AGENT_ALIAS = "worker-1085";
32
+ const ORIGIN_ALIAS = "boss-1085"; // the task's from_name — the derive target
33
+ const AGENT_TOK_ID = "tok_1085";
34
+
35
+ interface ToolHandler {
36
+ (args: any, extra?: any): Promise<{ content: Array<{ type: "text"; text: string }> }>;
37
+ }
38
+ interface Reply { ok?: boolean; message_id?: string; error?: string; message?: string; [k: string]: unknown; }
39
+
40
+ function cleanup() {
41
+ for (const t of ["tasks", "inbox", "task_events", "sessions", "nodes", "api_tokens", "network_members", "networks"]) {
42
+ try { db.run(`DELETE FROM ${t} WHERE network_id = ?1`, [NET_ID]); } catch {}
43
+ }
44
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [USER_ID]); } catch {}
45
+ }
46
+ beforeEach(cleanup);
47
+ afterAll(cleanup);
48
+
49
+ function seed(): { taskId: string } {
50
+ db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`, [USER_ID, USER_ID]);
51
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [NET_ID, NET_ID, USER_ID]);
52
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [USER_ID, NET_ID]);
53
+ db.run(`INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state) VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'), 'active')`, [NODE_ID, AGENT_ALIAS, AGENT_ALIAS, NET_ID, `host-${AGENT_ALIAS}`]);
54
+ db.run(`INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at) VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`, [`res_${AGENT_ALIAS}`, AGENT_ALIAS, NODE_ID, NET_ID]);
55
+ // The reply TARGET session (the task originator). Its alias is what the
56
+ // handler must derive from the task's from_name.
57
+ db.run(`INSERT INTO sessions (resume_id, alias, status, network_id, updated_at, last_seen_at) VALUES (?1, ?2, 'idle', ?3, datetime('now'), datetime('now'))`, [`res_${ORIGIN_ALIAS}`, ORIGIN_ALIAS, NET_ID]);
58
+ // A task from ORIGIN_ALIAS → the worker. Replying to it should land on
59
+ // ORIGIN_ALIAS even when the caller omits alias.
60
+ const taskId = `task_1085_${Date.now()}${Math.floor(Math.random() * 1000)}`;
61
+ db.run(`INSERT INTO tasks (task_id, from_name, to_name, status, content, requires_response, created_at, delivered_at, expires_at, network_id, priority) VALUES (?1, ?2, ?3, 'delivered', 'do the thing', 'reply', datetime('now'), datetime('now'), datetime('now', '+1 hour'), ?4, 'normal')`, [taskId, ORIGIN_ALIAS, AGENT_ALIAS, NET_ID]);
62
+ return { taskId };
63
+ }
64
+
65
+ async function getHandler(): Promise<ToolHandler> {
66
+ const server = new McpServer({ name: "test", version: "0" });
67
+ const tools: Record<string, ToolHandler> = {};
68
+ const originalTool = (server as any).tool.bind(server);
69
+ (server as any).tool = (name: string, ...rest: any[]) => {
70
+ const handler = rest[rest.length - 1];
71
+ if (typeof handler === "function") tools[name] = handler;
72
+ return originalTool(name, ...rest);
73
+ };
74
+ registerTools(server, {
75
+ enforceNetworkId: NET_ID,
76
+ enforceUserId: USER_ID,
77
+ callerAlias: AGENT_ALIAS,
78
+ callerTokenIsNetwork: true,
79
+ callerTokenId: AGENT_TOK_ID,
80
+ } as any);
81
+ const handler = tools.send_reply;
82
+ if (!handler) throw new Error("send_reply handler not registered");
83
+ return handler;
84
+ }
85
+
86
+ async function call(h: ToolHandler, args: any): Promise<Reply> {
87
+ return JSON.parse((await h(args)).content[0].text) as Reply;
88
+ }
89
+
90
+ describe("#1085 send_reply — alias optional, derived from in_reply_to", () => {
91
+ test("alias OMITTED + in_reply_to set → derives target from task from_name, reply lands on originator", async () => {
92
+ const { taskId } = seed();
93
+ const h = await getHandler();
94
+ const r = await call(h, { in_reply_to: taskId, text: "done", status: "replied", from_session: AGENT_ALIAS });
95
+ expect(r.ok).toBe(true);
96
+ // The reply inbox row must be addressed to the derived originator.
97
+ const row = db.get<{ session_name: string; in_reply_to: string }>(
98
+ "SELECT session_name, in_reply_to FROM inbox WHERE id = ?1", [r.message_id],
99
+ );
100
+ expect(row?.session_name).toBe(ORIGIN_ALIAS);
101
+ expect(row?.in_reply_to).toBe(taskId);
102
+ // And the original task is terminalized (no longer pending).
103
+ const task = db.get<{ status: string }>("SELECT status FROM tasks WHERE task_id = ?1", [taskId]);
104
+ expect(task?.status).toBe("replied");
105
+ });
106
+
107
+ test("alias EXPLICIT still works (unchanged path)", async () => {
108
+ const { taskId } = seed();
109
+ const h = await getHandler();
110
+ const r = await call(h, { alias: ORIGIN_ALIAS, in_reply_to: taskId, text: "done", from_session: AGENT_ALIAS });
111
+ expect(r.ok).toBe(true);
112
+ const row = db.get<{ session_name: string }>("SELECT session_name FROM inbox WHERE id = ?1", [r.message_id]);
113
+ expect(row?.session_name).toBe(ORIGIN_ALIAS);
114
+ });
115
+
116
+ test("alias OMITTED + no in_reply_to → clean error, not a crash", async () => {
117
+ seed();
118
+ const h = await getHandler();
119
+ const r = await call(h, { text: "orphan reply", from_session: AGENT_ALIAS });
120
+ expect(r.ok).toBe(false);
121
+ expect(r.error).toBe("reply_target_required");
122
+ });
123
+
124
+ test("alias OMITTED + in_reply_to points at nonexistent task → resolve error", async () => {
125
+ seed();
126
+ const h = await getHandler();
127
+ const r = await call(h, { in_reply_to: "task_does_not_exist_1085", text: "x", from_session: AGENT_ALIAS });
128
+ expect(r.ok).toBe(false);
129
+ expect(r.error).toBe("reply_target_unresolved");
130
+ });
131
+ });
package/src/server.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { parseDbTimestampMs } from "./db-timestamp.js";
2
3
  import { resolvePort } from "./resolve-port.js";
3
4
  import { normalizeSessionRuntime } from "./runtime-label.js";
4
5
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
@@ -51,6 +52,7 @@ import { handleExternalScheduleEditRequest } from "./external-schedule-edits.js"
51
52
  import { recordDeliveredStaleEvents } from "./task-lifecycle-watcher.js";
52
53
  import { SIDE_THREAD_FEATURE_FLAG, SideThreadCoordinator, SideThreadPortRegistry, SideThreadStore, type SideThreadActor, type SideThreadAttachmentRef, type SideThreadExecutionPort } from "./side-thread.js";
53
54
  import { handleSideThreadHttpRequest } from "./side-thread-http.js";
55
+ import { createProductionSideThreadTransport } from "./side-thread-production.js";
54
56
 
55
57
  const PORT = resolvePort(process.env.PORT);
56
58
  const HOST = process.env.HOST || "127.0.0.1";
@@ -77,6 +79,10 @@ const sideThreadCoordinator = new SideThreadCoordinator(new SideThreadStore(db),
77
79
  authorizeNode: (actor, networkId, nodeId) => authorizeSideThreadNode(actor, networkId, nodeId),
78
80
  authorizeAttachment: (actor, networkId, ref) => authorizeSideThreadAttachment(actor, networkId, ref),
79
81
  });
82
+ const productionSideThreadTransport = process.env[SIDE_THREAD_FEATURE_FLAG] === "1"
83
+ ? createProductionSideThreadTransport(db)
84
+ : null;
85
+ if (productionSideThreadTransport) installSideThreadExecutionPort(productionSideThreadTransport.port);
80
86
 
81
87
  if (AUTH_TOKEN) {
82
88
  console.warn("[commhub] COMMHUB_AUTH_TOKEN is deprecated and will be removed in v1.0. See RFC-001.");
@@ -1519,11 +1525,22 @@ return Bun.serve({
1519
1525
  return withCors(req, Response.json({ ok: false, error: restScope.denied }, { status: 403 }));
1520
1526
  }
1521
1527
 
1528
+ const sideActor = resolveSideThreadActor(req, resolveRequestAuth(req, { allowQueryToken: false }), isAdmin);
1529
+ const nodeCommandActor = sideActor?.kind === "node" && sideActor.boundNodeId && sideActor.boundNetworkId
1530
+ ? { tokenId: sideActor.tokenId, networkId: sideActor.boundNetworkId, nodeId: sideActor.boundNodeId }
1531
+ : null;
1532
+ if (productionSideThreadTransport) {
1533
+ const attachmentResponse = await productionSideThreadTransport.attachment(req, url, nodeCommandActor);
1534
+ if (attachmentResponse) return withCors(req, attachmentResponse);
1535
+ const commandResponse = await productionSideThreadTransport.handle({ req, url, actor: nodeCommandActor });
1536
+ if (commandResponse) return withCors(req, commandResponse);
1537
+ }
1538
+
1522
1539
  const sideThreadResponse = await handleSideThreadHttpRequest({
1523
1540
  req,
1524
1541
  url,
1525
1542
  // Durable prompts/results never accept query-string credentials.
1526
- actor: resolveSideThreadActor(req, resolveRequestAuth(req, { allowQueryToken: false }), isAdmin),
1543
+ actor: sideActor,
1527
1544
  coordinator: sideThreadCoordinator,
1528
1545
  resolveCapabilityTarget: resolveSideThreadCapabilityTarget,
1529
1546
  });
@@ -2992,7 +3009,10 @@ return Bun.serve({
2992
3009
  `, effectiveNetId);
2993
3010
 
2994
3011
  const nowMs = Date.now();
2995
- const ONLINE_MS = 60_000;
3012
+ // 心跳周期是 3 分钟(agent-node/src/cli.ts 的 report_status 定时器),
3013
+ // 窗口必须大于它,否则每次心跳后的 60s~180s 之间必然抖成 offline
3014
+ // (通信评审牛 #1279 独审④)。取 5min,与仓内既有 stale 口径一致。
3015
+ const ONLINE_MS = 5 * 60_000;
2996
3016
  const daemons = sqlRows
2997
3017
  .map(r => {
2998
3018
  let snapRole: string | null = null;
@@ -3010,7 +3030,7 @@ return Bun.serve({
3010
3030
  let lastSeenAt: string | null = null;
3011
3031
  if (r.session_last_seen) {
3012
3032
  lastSeenAt = r.session_last_seen;
3013
- const t = Date.parse(r.session_last_seen);
3033
+ const t = parseDbTimestampMs(r.session_last_seen);
3014
3034
  if (!isNaN(t)) online = (nowMs - t) <= ONLINE_MS;
3015
3035
  }
3016
3036
  let runtimes: string[] = [];