@sleep2agi/commhub-server 0.9.0-preview.46 → 0.9.0-preview.47

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.46",
3
+ "version": "0.9.0-preview.47",
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,122 @@
1
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { db } from "./db.js";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { registerTools } from "./tools.js";
5
+
6
+ // #1548 —— 一个活着、能收发任务的节点可以在名册里永远显示 `blocked`:
7
+ // 只有 report_completion 会把 sessions.status 拉回 idle,而现在大多数 agent 用
8
+ // send_reply(终态)结束任务、用 send_task 派活 —— 这两条路径此前都不碰 sessions.status。
9
+ // 「它刚回了我一条终态消息 / 刚派了一条任务」本身就是最强的活性证据。
10
+ // 只出 `blocked`;`working` 可能真在忙别的任务,不碰(保守)。
11
+ const NET_ID = "net_1548_blocked";
12
+ const USER_ID = "u_1548";
13
+ const NODE_ID = "node_1548_agent";
14
+ const AGENT_ALIAS = "agent-1548";
15
+ const DISPATCHER_NODE_ID = "node_1548_dispatcher";
16
+ const DISPATCHER_ALIAS = "dispatcher-1548";
17
+ const AGENT_TOK_ID = "tok_1548";
18
+
19
+ type ToolHandler = (args: any, extra?: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
20
+
21
+ function cleanup() {
22
+ for (const [sql, params] of [
23
+ ["DELETE FROM tasks WHERE network_id = ?1", [NET_ID]],
24
+ ["DELETE FROM inbox WHERE network_id = ?1", [NET_ID]],
25
+ ["DELETE FROM task_events WHERE network_id = ?1", [NET_ID]],
26
+ ["DELETE FROM sessions WHERE network_id = ?1", [NET_ID]],
27
+ ["DELETE FROM nodes WHERE network_id = ?1", [NET_ID]],
28
+ ["DELETE FROM api_tokens WHERE network_id = ?1", [NET_ID]],
29
+ ["DELETE FROM network_members WHERE network_id = ?1", [NET_ID]],
30
+ ["DELETE FROM networks WHERE network_id = ?1", [NET_ID]],
31
+ ["DELETE FROM users WHERE user_id = ?1", [USER_ID]],
32
+ ] as const) {
33
+ try { db.run(sql, params as any); } catch {}
34
+ }
35
+ }
36
+ beforeEach(cleanup);
37
+ afterAll(cleanup);
38
+
39
+ function seed(agentStatus: string) {
40
+ db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`, [USER_ID, USER_ID]);
41
+ 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]);
42
+ db.run(
43
+ `INSERT INTO network_members (user_id, network_id, role, joined_at)
44
+ VALUES (?1, ?2, 'owner', datetime('now'))`,
45
+ [USER_ID, NET_ID],
46
+ );
47
+ for (const [nodeId, alias] of [[DISPATCHER_NODE_ID, DISPATCHER_ALIAS], [NODE_ID, AGENT_ALIAS]] as const) {
48
+ db.run(
49
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state)
50
+ VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'), 'active')`,
51
+ [nodeId, alias, alias, NET_ID, `host-${alias}`],
52
+ );
53
+ }
54
+ db.run(
55
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
56
+ VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
57
+ [`res_${DISPATCHER_ALIAS}`, DISPATCHER_ALIAS, DISPATCHER_NODE_ID, NET_ID],
58
+ );
59
+ db.run(
60
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
61
+ VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'))`,
62
+ [`res_${AGENT_ALIAS}`, AGENT_ALIAS, agentStatus, NODE_ID, NET_ID],
63
+ );
64
+ db.run(
65
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
66
+ VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
67
+ [AGENT_TOK_ID, USER_ID, NET_ID, `node:${AGENT_ALIAS}`, `hash_${AGENT_TOK_ID}`],
68
+ );
69
+ }
70
+ function makeTask(): string {
71
+ const id = "t_" + Math.random().toString(36).slice(2, 14);
72
+ db.run(
73
+ `INSERT INTO tasks (task_id, from_name, to_name, to_node_id, priority, status, content, requires_response, created_at, network_id)
74
+ VALUES (?1, ?2, ?3, ?4, 'normal', 'delivered', 'test', 'reply', datetime('now'), ?5)`,
75
+ [id, DISPATCHER_ALIAS, AGENT_ALIAS, NODE_ID, NET_ID],
76
+ );
77
+ return id;
78
+ }
79
+ function buildHandlers(): Record<string, ToolHandler> {
80
+ const server = new McpServer({ name: "test", version: "0" }) as any;
81
+ const tools: Record<string, ToolHandler> = {};
82
+ const origTool = server.tool.bind(server);
83
+ server.tool = (name: string, _desc: string, _schema: any, handler: ToolHandler) => { tools[name] = handler; return origTool(name, _desc, _schema, handler); };
84
+ const origRegisterTool = server.registerTool?.bind(server);
85
+ if (origRegisterTool) {
86
+ server.registerTool = (name: string, _cfg: any, handler: ToolHandler) => { tools[name] = handler; return origRegisterTool(name, _cfg, handler); };
87
+ }
88
+ registerTools(server, undefined, NET_ID, USER_ID, AGENT_ALIAS, true, AGENT_TOK_ID);
89
+ return tools;
90
+ }
91
+ async function call(handler: ToolHandler, args: any): Promise<any> {
92
+ const r = await handler(args);
93
+ return JSON.parse(r.content[0]!.text);
94
+ }
95
+ function agentStatus(): string {
96
+ return (db.get("SELECT status FROM sessions WHERE alias = ?1 AND network_id = ?2", [AGENT_ALIAS, NET_ID]) as { status: string }).status;
97
+ }
98
+
99
+ describe("#1548 blocked has an exit: a terminal reply or an outbound task proves the agent is alive", () => {
100
+ test("terminal send_reply from a blocked agent puts it back to idle", async () => {
101
+ seed("blocked");
102
+ const tools = buildHandlers();
103
+ const taskId = makeTask();
104
+ const r = await call(tools.send_reply!, { text: "done", in_reply_to: taskId, status: "replied", from_session: AGENT_ALIAS, network_id: NET_ID });
105
+ expect(r.ok, JSON.stringify(r)).toBe(true);
106
+ expect(agentStatus()).toBe("idle");
107
+ });
108
+ test("send_task from a blocked agent puts it back to idle", async () => {
109
+ seed("blocked");
110
+ const tools = buildHandlers();
111
+ const r = await call(tools.send_task!, { alias: DISPATCHER_ALIAS, task: "ping", priority: "normal", from_session: AGENT_ALIAS, network_id: NET_ID });
112
+ expect(r.ok, JSON.stringify(r)).toBe(true);
113
+ expect(agentStatus()).toBe("idle");
114
+ });
115
+ test("a working agent is left alone (it may be busy with another task)", async () => {
116
+ seed("working");
117
+ const tools = buildHandlers();
118
+ const taskId = makeTask();
119
+ await call(tools.send_reply!, { text: "done", in_reply_to: taskId, status: "replied", from_session: AGENT_ALIAS, network_id: NET_ID });
120
+ expect(agentStatus()).toBe("working");
121
+ });
122
+ });
package/src/tools.ts CHANGED
@@ -362,6 +362,19 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
362
362
  return sql;
363
363
  };
364
364
 
365
+ /** #1548 —— 把一个还标着 blocked 的会话拉回 idle。只在「它刚发出终态回复 / 刚派了任务」这两个活性证据处调用。 */
366
+ const releaseBlockedSession = (alias: string | null | undefined, networkId: string | null | undefined): void => {
367
+ if (!alias) return;
368
+ const params: any[] = [alias];
369
+ let sql = "UPDATE sessions SET status = 'idle', updated_at = datetime('now') WHERE alias = ?1 AND status = 'blocked'";
370
+ sql = addScope(sql, params, networkId ?? null);
371
+ try {
372
+ db.run(sql, params);
373
+ } catch {
374
+ // 展示层的一次修正失败不能影响主路径。
375
+ }
376
+ };
377
+
365
378
  type ReadScope = { networkId?: string | null; networkIds?: string[] | null; denied?: string };
366
379
 
367
380
  // Delegates to the shared membership query (network-scope.ts) — was a
@@ -1598,6 +1611,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1598
1611
  let touchSql = "UPDATE sessions SET task = ?1, updated_at = datetime('now') WHERE alias = ?2";
1599
1612
  touchSql = addScope(touchSql, touchParams, effectiveNetId);
1600
1613
  db.run(touchSql, touchParams);
1614
+ // #1548 —— 能派活的发送方不可能真的 blocked;此处出 blocked → idle。
1615
+ releaseBlockedSession(from_session, effectiveNetId);
1601
1616
  });
1602
1617
  logTaskEvent(id, null, "delivered", from_session, parentTaskId ? `→ ${targetAlias} (parent=${parentTaskId.slice(0,8)})` : `→ ${targetAlias}`);
1603
1618
  // Only stamp the dedup index after the inbox/tasks transaction
@@ -1938,6 +1953,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1938
1953
  throw new Error(`reply_atomic_cas_failed:${in_reply_to}`);
1939
1954
  }
1940
1955
  syncScheduledRunForTask(in_reply_to, effectiveNetId);
1956
+ // #1548 —— 终态回复是最强的活性证据:一个还标着 blocked 的发送方在此回到 idle。
1957
+ // 只出 blocked;working 可能真在忙别的任务,不碰。
1958
+ releaseBlockedSession(from_session, effectiveNetId);
1941
1959
  return { ok: true as const, replyLogged: true };
1942
1960
  }
1943
1961
  return { ok: true as const, replyLogged: false };