@sleep2agi/commhub-server 0.9.0-preview.141 → 0.9.0-preview.143
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.
|
|
3
|
+
"version": "0.9.0-preview.143",
|
|
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,123 @@
|
|
|
1
|
+
// #1974 —— 多网络账号给「另一个网络里的 Agent」发消息,hub 侧也要能发出去。
|
|
2
|
+
//
|
|
3
|
+
// 起因(admin 2026-10-21 截图 + hub 拒绝日志):
|
|
4
|
+
// [15:55:54] tang → /api/task → tang02: REJECTED 404 alias_not_found (net=net_ac567b4ab74e)
|
|
5
|
+
//
|
|
6
|
+
// 客户端的 Agent 列表是跨网络的(GET /api/status?light=1 不带 network_id ⇒ 返回调用者所属的
|
|
7
|
+
// 全部网络),发送却带着「登录时选的那一个网络」。这里用真实 HTTP + 临时库复现那个形状:
|
|
8
|
+
// * admin 拥有 NET_A,里面有 Agent `cross-agent`;
|
|
9
|
+
// * tang 注册 ⇒ 自己的个人网络 NET_B(owner),同时是 NET_A 的 admin 成员(agent_access 'all');
|
|
10
|
+
// * tang 用 NET_B 发给 `cross-agent` ⇒ 修之前 404 alias_not_found,修之后送达 NET_A。
|
|
11
|
+
//
|
|
12
|
+
// 三条红线各有断言:同名在两个网络都有 ⇒ 不猜(仍 404);没授权的网络 ⇒ 不去;不存在的 alias ⇒ 404。
|
|
13
|
+
|
|
14
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
15
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { addNetworkMember, register } from "./auth.js";
|
|
19
|
+
import { db } from "./db.js";
|
|
20
|
+
|
|
21
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-1974-alias-net-"));
|
|
22
|
+
let BASE = "";
|
|
23
|
+
let hub: any = null;
|
|
24
|
+
const PW = "AliasNetPassw0rd!x";
|
|
25
|
+
let NET_A = "";
|
|
26
|
+
let NET_B = "";
|
|
27
|
+
let tangToken = "";
|
|
28
|
+
let tangUserId = "";
|
|
29
|
+
|
|
30
|
+
const stamp = Date.now();
|
|
31
|
+
|
|
32
|
+
let sessionSeq = 0;
|
|
33
|
+
function insertSession(alias: string, networkId: string, nodeId: string | null = null) {
|
|
34
|
+
sessionSeq += 1;
|
|
35
|
+
db.run(
|
|
36
|
+
`INSERT INTO sessions (resume_id, alias, node_id, network_id, status, updated_at, last_seen_at)
|
|
37
|
+
VALUES (?1, ?2, ?3, ?4, 'idle', datetime('now'), datetime('now'))`,
|
|
38
|
+
[`resume_${alias}_${stamp}_${sessionSeq}`, alias, nodeId, networkId],
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function postTask(token: string, body: any) {
|
|
43
|
+
const res = await fetch(`${BASE}/api/task`, {
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
46
|
+
body: JSON.stringify(body),
|
|
47
|
+
});
|
|
48
|
+
const text = await res.text();
|
|
49
|
+
let json: any = text;
|
|
50
|
+
try { json = JSON.parse(text); } catch { /* keep text */ }
|
|
51
|
+
return { status: res.status, json };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
beforeAll(async () => {
|
|
55
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
56
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
57
|
+
process.env.HOST = "127.0.0.1";
|
|
58
|
+
|
|
59
|
+
const admin = register(`aliasnet_admin_${stamp}`, PW, undefined, "Admin");
|
|
60
|
+
expect(admin.ok).toBe(true);
|
|
61
|
+
NET_A = admin.network_id!;
|
|
62
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [admin.user!.user_id]);
|
|
63
|
+
|
|
64
|
+
// admin 名下的 Agent:只在 NET_A 里。
|
|
65
|
+
insertSession("cross-agent", NET_A, `node_cross_${stamp}`);
|
|
66
|
+
|
|
67
|
+
// tang:自己的个人网络 NET_B(owner)+ NET_A 的 admin 成员(两个网络 agent_access 都是 'all',
|
|
68
|
+
// 这正是现网 tang 的形状:owner 个人网络 + admin 团队网络)。
|
|
69
|
+
const tang = register(`aliasnet_tang_${stamp}`, PW, undefined, "Tang");
|
|
70
|
+
expect(tang.ok).toBe(true);
|
|
71
|
+
NET_B = tang.network_id!;
|
|
72
|
+
tangToken = tang.token!;
|
|
73
|
+
tangUserId = tang.user!.user_id;
|
|
74
|
+
expect(addNetworkMember(NET_A, tangUserId, "admin", admin.user!.user_id, { agentAccess: "all" }).ok).toBe(true);
|
|
75
|
+
|
|
76
|
+
// 同名 alias 在**请求的网络之外**的两个网络里都有 ⇒ 不猜。
|
|
77
|
+
// 需要第三个网络:另一个用户建自己的网络,tang 也是它的成员。
|
|
78
|
+
const third = register(`aliasnet_third_${stamp}`, PW, undefined, "Third");
|
|
79
|
+
expect(third.ok).toBe(true);
|
|
80
|
+
const NET_C = third.network_id!;
|
|
81
|
+
expect(addNetworkMember(NET_C, tangUserId, "member", third.user!.user_id, { agentAccess: "all" }).ok).toBe(true);
|
|
82
|
+
insertSession("twin-agent", NET_A, `node_twin_a_${stamp}`);
|
|
83
|
+
insertSession("twin-agent", NET_C, `node_twin_c_${stamp}`);
|
|
84
|
+
|
|
85
|
+
const mod: any = await import("./server.js");
|
|
86
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
87
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
afterAll(() => {
|
|
91
|
+
try { hub?.stop?.(); } catch { /* ignore */ }
|
|
92
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("POST /api/task across the caller's own networks (#1974)", () => {
|
|
96
|
+
test("an alias in the caller's other network is delivered there, not 404", async () => {
|
|
97
|
+
const res = await postTask(tangToken, { alias: "cross-agent", task: "hi", network_id: NET_B });
|
|
98
|
+
expect(res.status).toBe(200);
|
|
99
|
+
expect(res.json.ok).toBe(true);
|
|
100
|
+
// 落在 NET_A(那个 alias 真正所在的网络),不是请求里带的 NET_B。
|
|
101
|
+
expect(res.json.actual_to?.network_id ?? res.json.actual_to?.networkId).toBe(NET_A);
|
|
102
|
+
const row = db.get<{ network_id: string }>("SELECT network_id FROM tasks WHERE task_id = ?1", [res.json.task_id]);
|
|
103
|
+
expect(row?.network_id).toBe(NET_A);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("the same alias in two of the caller's networks is not guessed", async () => {
|
|
107
|
+
const res = await postTask(tangToken, { alias: "twin-agent", task: "hi", network_id: NET_B });
|
|
108
|
+
expect(res.status).toBe(404);
|
|
109
|
+
expect(res.json.error).toBe("alias_not_found");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("an alias nobody has is still 404", async () => {
|
|
113
|
+
const res = await postTask(tangToken, { alias: "nowhere-agent", task: "hi", network_id: NET_B });
|
|
114
|
+
expect(res.status).toBe(404);
|
|
115
|
+
expect(res.json.error).toBe("alias_not_found");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("the requested network still wins when it has the alias", async () => {
|
|
119
|
+
const res = await postTask(tangToken, { alias: "cross-agent", task: "hi2", network_id: NET_A });
|
|
120
|
+
expect(res.status).toBe(200);
|
|
121
|
+
expect(res.json.actual_to?.network_id ?? res.json.actual_to?.networkId).toBe(NET_A);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// #1974 —— alias 不在请求的网络里、但在调用者另一个网络里(且唯一)时,发到那儿去。
|
|
2
|
+
//
|
|
3
|
+
// 三条红线各有���一条断言:只找调用者已加入的网络、同名不猜、没权限不换。
|
|
4
|
+
import { describe, expect, test } from "bun:test";
|
|
5
|
+
import { pickAliasNetwork } from "./rest-alias-network-fallback.js";
|
|
6
|
+
|
|
7
|
+
const base = {
|
|
8
|
+
alias: "tang02",
|
|
9
|
+
requestedNetworkId: "net_own",
|
|
10
|
+
memberNetworkIds: ["net_own", "net_team"],
|
|
11
|
+
networksWithAlias: ["net_team"],
|
|
12
|
+
canWrite: () => true,
|
|
13
|
+
canMessage: () => true,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe("pickAliasNetwork (#1974)", () => {
|
|
17
|
+
test("the alias lives in exactly one other member network ⇒ use it", () => {
|
|
18
|
+
expect(pickAliasNetwork({ ...base })).toBe("net_team");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("the alias is in the requested network too ⇒ do not switch", () => {
|
|
22
|
+
expect(pickAliasNetwork({ ...base, networksWithAlias: ["net_own", "net_team"] })).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("the same alias in two other networks ⇒ do not guess", () => {
|
|
26
|
+
expect(pickAliasNetwork({
|
|
27
|
+
...base,
|
|
28
|
+
memberNetworkIds: ["net_own", "net_a", "net_b"],
|
|
29
|
+
networksWithAlias: ["net_a", "net_b"],
|
|
30
|
+
})).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("a network the caller does not belong to is never used", () => {
|
|
34
|
+
expect(pickAliasNetwork({
|
|
35
|
+
...base,
|
|
36
|
+
memberNetworkIds: ["net_own"],
|
|
37
|
+
networksWithAlias: ["net_team"],
|
|
38
|
+
})).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("no write access in that network ⇒ keep the 404", () => {
|
|
42
|
+
expect(pickAliasNetwork({ ...base, canWrite: () => false })).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("no message permission in that network ⇒ keep the 404", () => {
|
|
46
|
+
expect(pickAliasNetwork({ ...base, canMessage: () => false })).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("the alias is nowhere ⇒ keep the 404", () => {
|
|
50
|
+
expect(pickAliasNetwork({ ...base, networksWithAlias: [] })).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("no membership at all ⇒ keep the 404", () => {
|
|
54
|
+
expect(pickAliasNetwork({ ...base, memberNetworkIds: [] })).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("an empty alias is never sent anywhere", () => {
|
|
58
|
+
expect(pickAliasNetwork({ ...base, alias: " " })).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// #1974 —— 「alias 不在请求的网络里」时,发到调用者**另一个**网络里那个 alias 所在的网络。
|
|
2
|
+
//
|
|
3
|
+
// 起因(admin 2026-10-21 截图 + hub 拒绝日志):
|
|
4
|
+
// [15:55:54] tang → /api/task → tang02: REJECTED 404 alias_not_found (net=net_ac567b4ab74e)
|
|
5
|
+
//
|
|
6
|
+
// 客户端的 Agent 列表是**跨网络**的:`GET /api/status?light=1` 不带 network_id,
|
|
7
|
+
// hub 就按「调用者属于哪些网络」返回。tang 因此同时看到默认网络(net_399bdf86f528)的
|
|
8
|
+
// tang02/03/04 和自己个人网络(net_ac567b4ab74e)里的 Agent。发送却带着「登录时选定的
|
|
9
|
+
// 那一个网络」—— 多网络账号(自己网络的 owner + 团队网络的 admin)两个网络对它都是
|
|
10
|
+
// agent_access='all',pickDefaultNetworkId 于是选中了自己的个人网络 ⇒ 给团队网络的
|
|
11
|
+
// Agent 发消息一律 404 alias_not_found,客户端只显示「没有找到这个 Agent」+「未送达」。
|
|
12
|
+
//
|
|
13
|
+
// 客户端侧已有修法(用 alias 所在网络发),但那只在**新客户端**里生效;已经在用的旧客户端
|
|
14
|
+
// 仍然发不出去。这里补 Hub 侧:alias 唯一地存在于调用者的另一个网络、且调用者在那个网络
|
|
15
|
+
// 有写权限 ⇒ 就发到那儿去。旧客户端立刻受益,不用等升级。
|
|
16
|
+
//
|
|
17
|
+
// 三条红线:
|
|
18
|
+
// ① 只在调用者**已加入**的网络里找(不跨租户、不做 alias 探测);
|
|
19
|
+
// ② 同一个 alias 在调用者的多个网络里都存在 ⇒ **不猜**,保持 404;
|
|
20
|
+
// ③ 调用者在目标网络没有写权限(受限成员没被授权这个 Agent)⇒ 保持 404/403。
|
|
21
|
+
//
|
|
22
|
+
// 纯函数:不碰 db、不碰 http,好测。
|
|
23
|
+
|
|
24
|
+
export interface AliasNetworkFallbackInput {
|
|
25
|
+
/** 请求里带的 alias(已按请求网络 canonical 化)。 */
|
|
26
|
+
alias: string;
|
|
27
|
+
/** 请求带/推出的那个网络。 */
|
|
28
|
+
requestedNetworkId: string | null;
|
|
29
|
+
/** 调用者所属的全部网络。 */
|
|
30
|
+
memberNetworkIds: readonly string[];
|
|
31
|
+
/** 上面这些网络里,真的存在这个 alias 的网络(去重后)。 */
|
|
32
|
+
networksWithAlias: readonly string[];
|
|
33
|
+
/** 调用者在这个网络能不能写。 */
|
|
34
|
+
canWrite: (networkId: string) => boolean;
|
|
35
|
+
/** 调用者能不能给这个 alias 发消息(受限成员的 Agent 授权)。 */
|
|
36
|
+
canMessage: (networkId: string) => boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 该发到哪个网络?返回 null = 保持原来的 404。
|
|
41
|
+
*
|
|
42
|
+
* 只在「请求的网络里没有这个 alias」时才会换网络;请求网络里有 ⇒ 传 requestedNetworkId
|
|
43
|
+
* 之外一律不动(调用方只在 not_found 时才问这个函数)。
|
|
44
|
+
*/
|
|
45
|
+
export function pickAliasNetwork(input: AliasNetworkFallbackInput): string | null {
|
|
46
|
+
const alias = (input.alias ?? '').trim();
|
|
47
|
+
if (!alias) return null;
|
|
48
|
+
const requested = (input.requestedNetworkId ?? '').trim();
|
|
49
|
+
const members = new Set((input.memberNetworkIds ?? []).map(n => (n ?? '').trim()).filter(Boolean));
|
|
50
|
+
if (members.size === 0) return null;
|
|
51
|
+
|
|
52
|
+
// 请求的网络里本来就有这个 alias ⇒ 不换网络(它就在那儿,不用猜)。
|
|
53
|
+
const withAlias = [...new Set((input.networksWithAlias ?? []).map(n => (n ?? '').trim()).filter(Boolean))];
|
|
54
|
+
if (requested && withAlias.includes(requested)) return null;
|
|
55
|
+
// 候选 = 调用者所属、且真的有这个 alias 的网络;请求网络本身不算。
|
|
56
|
+
const candidates = withAlias.filter(n => n !== requested && members.has(n));
|
|
57
|
+
if (candidates.length === 0) return null;
|
|
58
|
+
// 多个网络都有同名 alias ⇒ 不猜(alias 探测的防线)。
|
|
59
|
+
if (candidates.length > 1) return null;
|
|
60
|
+
|
|
61
|
+
const only = candidates[0];
|
|
62
|
+
if (!input.canWrite(only)) return null;
|
|
63
|
+
if (!input.canMessage(only)) return null;
|
|
64
|
+
return only;
|
|
65
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { notifyExpiredTasks, type ExpiredTaskRow } from "./task-expiry-notice.js
|
|
|
32
32
|
import { expireStaleOpenTasks } from "./task-stale-open.js";
|
|
33
33
|
import { flagOrphanTasks } from "./task-orphan.js";
|
|
34
34
|
import { addAgentNetworkScope, addHumanNetworkScope, addNetworkScope, addOwnTrafficScope, addAgentTimelineScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, resolveRestWriteNetworkId, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
35
|
+
import { pickAliasNetwork } from "./rest-alias-network-fallback.js";
|
|
35
36
|
import { lifecycleProjections, lifecycleRequestResponse } from "./node-lifecycle-read.js";
|
|
36
37
|
import { listAdoptionCandidates, snapshotReportsAdoptionDiscovery } from "./adoption-candidates.js";
|
|
37
38
|
import { restrictedMemberSeesFile, restrictedMemberAttachmentsDenied } from "./restricted-files.js";
|
|
@@ -3779,6 +3780,37 @@ return Bun.serve({
|
|
|
3779
3780
|
message: "network_id is required when the user token has zero or multiple network memberships",
|
|
3780
3781
|
}, { status: 400 })));
|
|
3781
3782
|
}
|
|
3783
|
+
// #1974 —— alias 不在请求的网络里、但在调用者**另一个**网络里(且唯一)⇒ 发到那儿去。
|
|
3784
|
+
// 客户端的 Agent 列表是跨网络的(/api/status?light=1 不带 network_id),发送却带着
|
|
3785
|
+
// 「登录时选的那一个网络」;多网络账号因此给别的网络里的 Agent 发消息一律 404
|
|
3786
|
+
// (tang → tang02,2026-10-21)。旧客户端不改也能立刻受益。
|
|
3787
|
+
// 找不到 / 多个网络同名 / 没权限 ⇒ 不动,保持原来的 404。
|
|
3788
|
+
if (restAuth && taskNetId) {
|
|
3789
|
+
const canonicalAlias = resolveCanonicalAlias(taskNetId, body.alias).alias;
|
|
3790
|
+
const memberNets = getUserNetworkIds(restAuth.userId);
|
|
3791
|
+
const netsWithAlias = memberNets.filter(n => !!db.get(
|
|
3792
|
+
"SELECT 1 AS ok FROM sessions WHERE alias = ?1 AND network_id = ?2",
|
|
3793
|
+
[canonicalAlias, n],
|
|
3794
|
+
));
|
|
3795
|
+
const alt = pickAliasNetwork({
|
|
3796
|
+
alias: canonicalAlias,
|
|
3797
|
+
requestedNetworkId: taskNetId,
|
|
3798
|
+
memberNetworkIds: memberNets,
|
|
3799
|
+
networksWithAlias: netsWithAlias,
|
|
3800
|
+
canWrite: (net) => canRestWriteNetwork(restAuth, net, isAdmin),
|
|
3801
|
+
canMessage: (net) => {
|
|
3802
|
+
if (isAgentRestricted(restAuth.userId, net)) {
|
|
3803
|
+
const row = db.get<{ node_id: string | null }>(
|
|
3804
|
+
"SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2",
|
|
3805
|
+
[canonicalAlias, net],
|
|
3806
|
+
);
|
|
3807
|
+
return canMessageAgent(restAuth.userId, net, { alias: canonicalAlias, nodeId: row?.node_id ?? null });
|
|
3808
|
+
}
|
|
3809
|
+
return true;
|
|
3810
|
+
},
|
|
3811
|
+
});
|
|
3812
|
+
if (alt) taskNetId = alt;
|
|
3813
|
+
}
|
|
3782
3814
|
// 多用户 Agent 权限:受限成员只能给授权且 can_message 的 Agent 发;
|
|
3783
3815
|
// 目标不存在 / 没授权 / 只读授权 → 同一个 403,不留 alias 探测差异。发件人固定为自己的用户名。
|
|
3784
3816
|
const restrictedSender = !!restAuth && !isAdmin && !restAuth.networkId && isAgentRestricted(restAuth.userId, taskNetId!);
|
|
@@ -961,6 +961,65 @@ describe("RFC-027 PR1.2a — restart_node resets lifecycle_state to 'active'", (
|
|
|
961
961
|
});
|
|
962
962
|
});
|
|
963
963
|
|
|
964
|
+
// ── #1511 — restart_node 在途任务默认拒绝(只提示,不自动重启) ──────
|
|
965
|
+
//
|
|
966
|
+
// 节点侧 exit-75 的 drain 只等 60 秒,超时会硬打断当前回合;所以「正在跑
|
|
967
|
+
// 任务」时 Hub 端在派发前就拒绝,计数规则与 stop_node 完全同一份
|
|
968
|
+
// (#2022/#2029 修订版,实现在 countInFlightTasks)。客户端语义:保存密钥
|
|
969
|
+
// 后若节点忙,不安排重启,只提示等任务完成或去节点机器上手动重启。
|
|
970
|
+
describe("restart_node — in-flight default-refuse (#1511)", () => {
|
|
971
|
+
test("live task row → node_busy_in_flight, no dispatch row, state untouched", async () => {
|
|
972
|
+
setupAlphaNetwork();
|
|
973
|
+
seedTaskWithInbox(NET_A, CHILD_A_ALIAS, { task_id: "task_1511_live", status: "delivered" });
|
|
974
|
+
const r = await call(buildHandlers(USER_A_ID).restart_node, { node_id: CHILD_A_ID, network_id: NET_A });
|
|
975
|
+
expect(r.ok).toBe(false);
|
|
976
|
+
expect(r.error).toBe("node_busy_in_flight");
|
|
977
|
+
expect(r.in_flight_count).toBe(1);
|
|
978
|
+
expect(typeof r.hint).toBe("string");
|
|
979
|
+
const rows = db.all<{ update_id: string }>(`SELECT update_id FROM node_config_updates WHERE node_id = ?1`, [CHILD_A_ID]);
|
|
980
|
+
expect(rows.length).toBe(0);
|
|
981
|
+
expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active");
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
test("unacked legacy inbox rows count too — same rule as stop", async () => {
|
|
985
|
+
setupAlphaNetwork();
|
|
986
|
+
seedInbox(NET_A, CHILD_A_ALIAS, 2);
|
|
987
|
+
const r = await call(buildHandlers(USER_A_ID).restart_node, { node_id: CHILD_A_ID, network_id: NET_A });
|
|
988
|
+
expect(r.ok).toBe(false);
|
|
989
|
+
expect(r.error).toBe("node_busy_in_flight");
|
|
990
|
+
expect(r.in_flight_count).toBe(2);
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
test("terminal-only rows → restart dispatches (parity with #2022 stop rule)", async () => {
|
|
994
|
+
setupAlphaNetwork();
|
|
995
|
+
seedTaskWithInbox(NET_A, CHILD_A_ALIAS, { task_id: "task_1511_replied", status: "replied" });
|
|
996
|
+
const r = await call(buildHandlers(USER_A_ID).restart_node, { node_id: CHILD_A_ID, network_id: NET_A });
|
|
997
|
+
expect(r.ok).toBe(true);
|
|
998
|
+
expect(r.apply_mode).toBe("restart_only");
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
test("2h-old live task is never aged out — status wins over age (same as stop)", async () => {
|
|
1002
|
+
setupAlphaNetwork();
|
|
1003
|
+
seedTaskWithInbox(NET_A, CHILD_A_ALIAS, {
|
|
1004
|
+
task_id: "task_1511_running_120m", status: "running",
|
|
1005
|
+
task_age: "-120 minutes", inbox_age: "-120 minutes", expires_in: "-60 minutes",
|
|
1006
|
+
});
|
|
1007
|
+
const r = await call(buildHandlers(USER_A_ID).restart_node, { node_id: CHILD_A_ID, network_id: NET_A });
|
|
1008
|
+
expect(r.ok).toBe(false);
|
|
1009
|
+
expect(r.error).toBe("node_busy_in_flight");
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
test("stopped node + live-row corpse → restart still dispatches (un-stop must not be blocked)", async () => {
|
|
1013
|
+
setupAlphaNetwork();
|
|
1014
|
+
db.run(`UPDATE nodes SET lifecycle_state = 'stopped' WHERE node_id = ?1`, [CHILD_A_ID]);
|
|
1015
|
+
seedTaskWithInbox(NET_A, CHILD_A_ALIAS, { task_id: "task_1511_corpse", status: "delivered" });
|
|
1016
|
+
const r = await call(buildHandlers(USER_A_ID).restart_node, { node_id: CHILD_A_ID, network_id: NET_A });
|
|
1017
|
+
expect(r.ok).toBe(true);
|
|
1018
|
+
expect(r.apply_mode).toBe("restart_only");
|
|
1019
|
+
expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active");
|
|
1020
|
+
});
|
|
1021
|
+
});
|
|
1022
|
+
|
|
964
1023
|
// ── #1286 stale-deleting 重派出口 ──────────────────────────────────
|
|
965
1024
|
//
|
|
966
1025
|
// 修复前 daemon 丢 ack 会让行永远停在 deleting:重发被挡、force 也一样。
|
package/src/tools.ts
CHANGED
|
@@ -4160,6 +4160,28 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4160
4160
|
);
|
|
4161
4161
|
|
|
4162
4162
|
|
|
4163
|
+
// #1511 — stop_node(§4.3 D4)与 restart_node 共用的「在途任务」计数。
|
|
4164
|
+
// 计数规则见 stop_node 内的长注释(#2022/#2029 修订:能成为「本节点正在做的
|
|
4165
|
+
// 工作」的行才算——tasks 行按状态判、终态不算且不设龄上限;无状态行按 60 分钟
|
|
4166
|
+
// 龄判)。抽成一处后,两个闸门共享同一份 SQL,不再各自漂移。
|
|
4167
|
+
const IN_FLIGHT_MAX_AGE_MINUTES = 60;
|
|
4168
|
+
function countInFlightTasks(alias: string, networkId: string | null): number {
|
|
4169
|
+
const row = db.get<{ n: number }>(
|
|
4170
|
+
`SELECT COUNT(*) AS n FROM inbox i
|
|
4171
|
+
LEFT JOIN tasks t
|
|
4172
|
+
ON i.type = 'task'
|
|
4173
|
+
AND t.task_id = COALESCE(i.task_id, i.id)
|
|
4174
|
+
WHERE i.session_name = ?1 AND i.acked = 0
|
|
4175
|
+
AND COALESCE(i.network_id, ?2) = ?2
|
|
4176
|
+
AND ((t.task_id IS NOT NULL
|
|
4177
|
+
AND t.status NOT IN ('replied', 'failed', 'cancelled', 'expired'))
|
|
4178
|
+
OR (t.task_id IS NULL
|
|
4179
|
+
AND i.created_at >= datetime('now', ?3)))`,
|
|
4180
|
+
alias, networkId, `-${IN_FLIGHT_MAX_AGE_MINUTES} minutes`,
|
|
4181
|
+
);
|
|
4182
|
+
return row?.n ?? 0;
|
|
4183
|
+
}
|
|
4184
|
+
|
|
4163
4185
|
server.tool(
|
|
4164
4186
|
"restart_node",
|
|
4165
4187
|
"Trigger a node restart without changing config. RFC-024 Vincent 2026-06-28 increment. Network-scoped (SEC-1); member+ role suffices (lifecycle ops are not privilege elevation).",
|
|
@@ -4189,6 +4211,27 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4189
4211
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false,
|
|
4190
4212
|
error: "adopted_restart_requires_daemon", message: "Use Stop, then Start for this adopted node." }) }] };
|
|
4191
4213
|
}
|
|
4214
|
+
// #1511 — 正在跑任务时不自动重启,只提示。节点侧 exit-75 的 drain 只等
|
|
4215
|
+
// 60 秒、超时会硬打断当前回合(agent-node cli.ts drainInFlightThink),
|
|
4216
|
+
// 所以 Hub 端在派发前就按 stop_node 同一条在途计数规则拒绝,客户端拿到
|
|
4217
|
+
// 明确二选一:等任务完成后重试,或到节点所在机器手动重启。
|
|
4218
|
+
//
|
|
4219
|
+
// 只对 active 节点生效。stopped 节点的进程已死、任务不可能正在跑,其
|
|
4220
|
+
// unacked 行是 stop 留下的尸体;restart_node 在 stopped 节点上是把
|
|
4221
|
+
// lifecycle 翻回 active 的出口,不能用尸体行把这条路堵死。
|
|
4222
|
+
// lifecycle_state 需单独取:resolveTargetNode 的 SELECT 不含该列。
|
|
4223
|
+
const restartLifecycle = db.get<{ lifecycle_state: string | null }>(
|
|
4224
|
+
"SELECT lifecycle_state FROM nodes WHERE node_id = ?1", nodeId,
|
|
4225
|
+
)?.lifecycle_state ?? null;
|
|
4226
|
+
if (restartLifecycle === "active") {
|
|
4227
|
+
const busyCount = countInFlightTasks(node.alias, node.network_id);
|
|
4228
|
+
if (busyCount > 0) {
|
|
4229
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
4230
|
+
ok: false, error: "node_busy_in_flight", in_flight_count: busyCount,
|
|
4231
|
+
hint: "节点正在执行任务,本次未安排重启;等它完成后再试,或到节点所在机器手动重启",
|
|
4232
|
+
}) }] };
|
|
4233
|
+
}
|
|
4234
|
+
}
|
|
4192
4235
|
// F-B reaper: same stale-supersede semantics as update_node_config,
|
|
4193
4236
|
// with same acked_at-anchored liveness clock (see update_node_config
|
|
4194
4237
|
// for the 通信龙 polish reasoning).
|
|
@@ -5449,21 +5492,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5449
5492
|
// ever retire them, so they get the age ceiling
|
|
5450
5493
|
// IN_FLIGHT_MAX_AGE_MINUTES on inbox.created_at.
|
|
5451
5494
|
// The count stays a per-alias inbox count: one PK join per row.
|
|
5452
|
-
|
|
5453
|
-
const
|
|
5454
|
-
`SELECT COUNT(*) AS n FROM inbox i
|
|
5455
|
-
LEFT JOIN tasks t
|
|
5456
|
-
ON i.type = 'task'
|
|
5457
|
-
AND t.task_id = COALESCE(i.task_id, i.id)
|
|
5458
|
-
WHERE i.session_name = ?1 AND i.acked = 0
|
|
5459
|
-
AND COALESCE(i.network_id, ?2) = ?2
|
|
5460
|
-
AND ((t.task_id IS NOT NULL
|
|
5461
|
-
AND t.status NOT IN ('replied', 'failed', 'cancelled', 'expired'))
|
|
5462
|
-
OR (t.task_id IS NULL
|
|
5463
|
-
AND i.created_at >= datetime('now', ?3)))`,
|
|
5464
|
-
node.alias, node.network_id, `-${IN_FLIGHT_MAX_AGE_MINUTES} minutes`,
|
|
5465
|
-
);
|
|
5466
|
-
const inFlight = inFlightRow?.n ?? 0;
|
|
5495
|
+
// 计数实现在上方 countInFlightTasks。#1511 让 restart_node 共用同一份规则,判据本身未变。
|
|
5496
|
+
const inFlight = countInFlightTasks(node.alias, node.network_id);
|
|
5467
5497
|
if (inFlight > 0 && !args.force) {
|
|
5468
5498
|
return { ok: false, error: "node_busy_in_flight", in_flight_count: inFlight, hint: "set force=true to override (audit logged)" };
|
|
5469
5499
|
}
|