@sleep2agi/commhub-server 0.9.0-preview.29 → 0.9.0-preview.30
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/README.md +115 -0
- package/package.json +2 -2
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/db.ts +65 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/rest-explicit-columns-http.test.ts +81 -19
- package/src/rest-identity.test.ts +10 -1
- package/src/rest-identity.ts +11 -4
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-tasks-http.test.ts +20 -1
- package/src/scheduled-tasks.ts +13 -2
- package/src/server.ts +135 -16
- package/src/side-thread-command-transport.test.ts +140 -0
- package/src/side-thread-command-transport.ts +309 -0
- package/src/side-thread-http-integration.test.ts +229 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread.test.ts +1038 -0
- package/src/side-thread.ts +2241 -0
- package/src/task-consumption.test.ts +69 -0
- package/src/task-diagnostic.test.ts +33 -0
- package/src/task-diagnostic.ts +32 -3
- package/src/task-idempotency-mcp.test.ts +5 -0
- package/src/task-lifecycle-watcher.test.ts +45 -5
- package/src/tools.ts +115 -9
|
@@ -18,6 +18,7 @@ function cleanup() {
|
|
|
18
18
|
try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET]); } catch {}
|
|
19
19
|
try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
|
|
20
20
|
try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
|
|
21
|
+
try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [NET]); } catch {}
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
function seed() {
|
|
@@ -29,6 +30,10 @@ function seed() {
|
|
|
29
30
|
"INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state) VALUES (?1, ?2, ?2, ?3, 'host', datetime('now'), datetime('now'), 'active')",
|
|
30
31
|
[`id_${alias}`, alias, NET],
|
|
31
32
|
);
|
|
33
|
+
db.run(
|
|
34
|
+
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, bound_node_id) VALUES (?1, ?1, ?2, ?3, ?4, ?5)",
|
|
35
|
+
[`token_${alias}`, USER, NET, alias, `id_${alias}`],
|
|
36
|
+
);
|
|
32
37
|
db.run(
|
|
33
38
|
"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'))",
|
|
34
39
|
[`resume_${alias}`, alias, `id_${alias}`, NET],
|
|
@@ -91,6 +96,70 @@ beforeEach(() => { cleanup(); seed(); });
|
|
|
91
96
|
afterAll(cleanup);
|
|
92
97
|
|
|
93
98
|
describe("task consumed_at identity and lifecycle", () => {
|
|
99
|
+
test("immutable node cursor paginates beyond 100 without alias or cross-node leakage", async () => {
|
|
100
|
+
for (let i = 0; i < 125; i++) {
|
|
101
|
+
db.run(
|
|
102
|
+
`INSERT INTO tasks (task_id, from_node_id, from_name, to_name, status, content, network_id, created_at)
|
|
103
|
+
VALUES (?1, ?2, ?3, ?4, 'replied', 'x', ?5, ?6)`,
|
|
104
|
+
[`page-${String(i).padStart(3, "0")}`, `id_${NODE_A}`, i < 60 ? "old-alias" : NODE_A, NODE_B, NET, `2026-01-01 00:${String(Math.floor(i / 60)).padStart(2, "0")}:${String(i % 60).padStart(2, "0")}`],
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
db.run(
|
|
108
|
+
`INSERT INTO tasks (task_id, from_node_id, from_name, to_name, status, content, network_id)
|
|
109
|
+
VALUES ('foreign-node-row', ?1, ?2, ?3, 'replied', 'x', ?4)`,
|
|
110
|
+
[`id_${NODE_B}`, NODE_A, NODE_A, NET],
|
|
111
|
+
);
|
|
112
|
+
const handler = toolsFor({ alias: NODE_A, nodeToken: true }).list_tasks;
|
|
113
|
+
const first = await call(handler, { from_node_id: `id_${NODE_A}`, durable_cursor: true, limit: 100 });
|
|
114
|
+
expect(first.capability).toBe("list_tasks.immutable-node-cursor.v1");
|
|
115
|
+
expect(first.tasks).toHaveLength(100);
|
|
116
|
+
const second = await call(handler, { from_node_id: `id_${NODE_A}`, durable_cursor: true, limit: 100, ...first.next_cursor });
|
|
117
|
+
expect(second.tasks).toHaveLength(25);
|
|
118
|
+
expect(new Set([...first.tasks, ...second.tasks].map((task: any) => task.task_id)).size).toBe(125);
|
|
119
|
+
expect([...first.tasks, ...second.tasks].some((task: any) => task.task_id === "foreign-node-row")).toBe(false);
|
|
120
|
+
expect((await call(handler, { from_node_id: `id_${NODE_B}`, durable_cursor: true, limit: 100 })).error).toBe("from_node_id_identity_mismatch");
|
|
121
|
+
});
|
|
122
|
+
test("terminal sequence paginates beyond 2000 and admits late completion of an old task", async () => {
|
|
123
|
+
db.run(
|
|
124
|
+
`INSERT INTO tasks (task_id, from_node_id, from_name, to_name, status, content, network_id, created_at)
|
|
125
|
+
VALUES ('old-open-task', ?1, 'renamed-sender', ?2, 'running', 'late', ?3, '2020-01-01 00:00:00')`,
|
|
126
|
+
[`id_${NODE_A}`, NODE_B, NET],
|
|
127
|
+
);
|
|
128
|
+
for (let i = 0; i < 2_001; i++) {
|
|
129
|
+
db.run(
|
|
130
|
+
`INSERT INTO tasks (task_id, from_node_id, from_name, to_name, status, content, network_id, created_at, completed_at)
|
|
131
|
+
VALUES (?1, ?2, ?3, ?4, 'replied', 'x', ?5, ?6, datetime('now'))`,
|
|
132
|
+
[`terminal-page-${String(i).padStart(4, "0")}`, `id_${NODE_A}`, i < 1_000 ? "old-alias" : NODE_A, NODE_B, NET, `2026-01-01 00:00:${String(i % 60).padStart(2, "0")}`],
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
db.run(
|
|
136
|
+
`INSERT INTO tasks (task_id, from_node_id, from_name, to_name, status, content, network_id)
|
|
137
|
+
VALUES ('terminal-cross-network', ?1, ?2, ?3, 'replied', 'x', 'other-network')`,
|
|
138
|
+
[`id_${NODE_A}`, NODE_A, NODE_B],
|
|
139
|
+
);
|
|
140
|
+
const handler = toolsFor({ alias: NODE_A, nodeToken: true }).list_tasks;
|
|
141
|
+
let after = 0;
|
|
142
|
+
const ids: string[] = [];
|
|
143
|
+
for (let page = 0; page < 30; page++) {
|
|
144
|
+
const response = await call(handler, {
|
|
145
|
+
from_node_id: `id_${NODE_A}`, durable_cursor: true, durable_terminal_cursor: true,
|
|
146
|
+
after_terminal_seq: after, limit: 100,
|
|
147
|
+
});
|
|
148
|
+
expect(response.capability).toBe("list_tasks.immutable-terminal-sequence.v2");
|
|
149
|
+
ids.push(...response.tasks.map((task: any) => task.task_id));
|
|
150
|
+
after = response.next_terminal_seq;
|
|
151
|
+
if (!response.has_more) break;
|
|
152
|
+
}
|
|
153
|
+
expect(ids).toHaveLength(2_001);
|
|
154
|
+
expect(new Set(ids).size).toBe(2_001);
|
|
155
|
+
expect(ids).not.toContain("terminal-cross-network");
|
|
156
|
+
db.run("UPDATE tasks SET status = 'failed', completed_at = datetime('now') WHERE task_id = 'old-open-task'");
|
|
157
|
+
const late = await call(handler, {
|
|
158
|
+
from_node_id: `id_${NODE_A}`, durable_cursor: true, durable_terminal_cursor: true,
|
|
159
|
+
after_terminal_seq: after, limit: 100,
|
|
160
|
+
});
|
|
161
|
+
expect(late.tasks.map((task: any) => task.task_id)).toEqual(["old-open-task"]);
|
|
162
|
+
}, 20_000);
|
|
94
163
|
test("enqueue and process-level ack keep consumed_at null", async () => {
|
|
95
164
|
const content = "connected process has not started a model turn";
|
|
96
165
|
const before = db.get<{ last_seen_at: string; task: string | null }>(
|
|
@@ -69,10 +69,43 @@ describe("#166 evidence-only task diagnostics", () => {
|
|
|
69
69
|
live_sse_connections: 1,
|
|
70
70
|
runtime_submitted: false,
|
|
71
71
|
runtime_consumed: false,
|
|
72
|
+
// 🔴 #1083 新增。这里保持 toEqual(整形断言)而**不**退化成 toMatchObject ——
|
|
73
|
+
// 隔壁那条测试的名字就是 "without widening the evidence",
|
|
74
|
+
// 这个严格性是故意的:它防的正是「有人往 evidence 里悄悄加字段」。
|
|
75
|
+
// 加宽了就把新字段写进来,不是把断言放松。
|
|
76
|
+
target_ever_reported_runtime_evidence: null,
|
|
72
77
|
},
|
|
73
78
|
});
|
|
74
79
|
});
|
|
75
80
|
|
|
81
|
+
// 🔴 #1083:【时序】与【能力】必须落在不同的 code 上。
|
|
82
|
+
// 实测背景:一个**确实回了长信**的任务与一个**毫无回音**的任务,
|
|
83
|
+
// 在旧实现下 diagnostic 逐字相同(都是 lifecycle_progress_without_runtime_evidence
|
|
84
|
+
// + inspect_runtime)⇒ 鉴别力 = 0。
|
|
85
|
+
test("a target that never reported runtime evidence gets its own code, not inspect_runtime", () => {
|
|
86
|
+
const d = diagnoseTask(base({ status: "acked", targetEverReportedRuntimeEvidence: false }));
|
|
87
|
+
expect(d.code).toBe("runtime_evidence_channel_absent");
|
|
88
|
+
expect(d.action_hint).toBe("wire_runtime_evidence");
|
|
89
|
+
expect(d.evidence.target_ever_reported_runtime_evidence).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("a target that HAS reported evidence before keeps the timing-flavoured code", () => {
|
|
93
|
+
const d = diagnoseTask(base({ status: "acked", targetEverReportedRuntimeEvidence: true }));
|
|
94
|
+
expect(d.code).toBe("lifecycle_progress_without_runtime_evidence");
|
|
95
|
+
expect(d.action_hint).toBe("inspect_runtime");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// 「Hub 没算这一项」不等于「没报过」—— 不知道就不改判。
|
|
99
|
+
test("an unknown capability (null / omitted) does not change the old verdict", () => {
|
|
100
|
+
const omitted = diagnoseTask(base({ status: "acked" }));
|
|
101
|
+
const explicitNull = diagnoseTask(base({ status: "acked", targetEverReportedRuntimeEvidence: null }));
|
|
102
|
+
for (const d of [omitted, explicitNull]) {
|
|
103
|
+
expect(d.code).toBe("lifecycle_progress_without_runtime_evidence");
|
|
104
|
+
expect(d.action_hint).toBe("inspect_runtime");
|
|
105
|
+
expect(d.evidence.target_ever_reported_runtime_evidence).toBeNull();
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
76
109
|
test("normalizes invalid SSE counts without widening the evidence", () => {
|
|
77
110
|
expect(diagnoseTask(base({ liveSseConnections: Number.NaN }))).toMatchObject({
|
|
78
111
|
code: "target_no_live_sse",
|
package/src/task-diagnostic.ts
CHANGED
|
@@ -8,6 +8,10 @@ export type TaskDiagnosticCode =
|
|
|
8
8
|
| "target_session_offline"
|
|
9
9
|
| "target_no_live_sse"
|
|
10
10
|
| "lifecycle_progress_without_runtime_evidence"
|
|
11
|
+
// 🔴 #1083:与上一条的区别是【时序】vs【能力】。上一条说的是「还没走到那一步」,
|
|
12
|
+
// 这一条说的是「这个目标从来没有报过任何一次运行时证据」——它不会走到。
|
|
13
|
+
// 两者处置相反:前者【等】,后者【别等,去接线】。
|
|
14
|
+
| "runtime_evidence_channel_absent"
|
|
11
15
|
| "delivered_waiting_for_agent";
|
|
12
16
|
|
|
13
17
|
export type TaskDiagnosticAction =
|
|
@@ -16,7 +20,8 @@ export type TaskDiagnosticAction =
|
|
|
16
20
|
| "check_target_registration"
|
|
17
21
|
| "restore_target"
|
|
18
22
|
| "restore_sse"
|
|
19
|
-
| "inspect_agent_queue"
|
|
23
|
+
| "inspect_agent_queue"
|
|
24
|
+
| "wire_runtime_evidence";
|
|
20
25
|
|
|
21
26
|
export interface TaskDiagnosticInput {
|
|
22
27
|
status: string;
|
|
@@ -25,6 +30,18 @@ export interface TaskDiagnosticInput {
|
|
|
25
30
|
targetSessionStatus: string | null;
|
|
26
31
|
targetSessionExists: boolean;
|
|
27
32
|
liveSseConnections: number;
|
|
33
|
+
/**
|
|
34
|
+
* 🔴 #1083:这个目标节点【历史上是否报过任何一次】运行时证据。
|
|
35
|
+
* `false` = 从来没报过 ⇒ `runtime_submitted_at`/`consumed_at` 为空**推不出**「没消费」,
|
|
36
|
+
* 它只说明这条证据通道不存在。
|
|
37
|
+
* `null`/省略 = Hub 没算这一项 ⇒ 保持旧行为(不因为「不知道」就改判)。
|
|
38
|
+
*
|
|
39
|
+
* 背景:RFC-035 §75 要求 agent-node 接线上报,但 agent 侧至今没有实现
|
|
40
|
+
* (`grep -rn runtime_evidence agent-node/src/ agent-network/src/` = 0 命中),
|
|
41
|
+
* 于是这两列对所有任务恒为 NULL。实测后果:一个**确实回了长信**的任务
|
|
42
|
+
* 与一个**毫无回音**的任务,diagnostic 读数逐字相同 ⇒ 鉴别力 = 0。
|
|
43
|
+
*/
|
|
44
|
+
targetEverReportedRuntimeEvidence?: boolean | null;
|
|
28
45
|
}
|
|
29
46
|
|
|
30
47
|
export interface TaskDiagnostic {
|
|
@@ -37,6 +54,8 @@ export interface TaskDiagnostic {
|
|
|
37
54
|
live_sse_connections: number;
|
|
38
55
|
runtime_submitted: boolean;
|
|
39
56
|
runtime_consumed: boolean;
|
|
57
|
+
/** `null` = Hub 没算这一项;`false` = 该目标从来没报过运行时证据。 */
|
|
58
|
+
target_ever_reported_runtime_evidence: boolean | null;
|
|
40
59
|
};
|
|
41
60
|
}
|
|
42
61
|
|
|
@@ -55,6 +74,8 @@ export function diagnoseTask(input: TaskDiagnosticInput): TaskDiagnostic {
|
|
|
55
74
|
live_sse_connections: liveSseConnections,
|
|
56
75
|
runtime_submitted: Boolean(input.runtimeSubmittedAt),
|
|
57
76
|
runtime_consumed: Boolean(input.consumedAt),
|
|
77
|
+
target_ever_reported_runtime_evidence:
|
|
78
|
+
input.targetEverReportedRuntimeEvidence ?? null,
|
|
58
79
|
};
|
|
59
80
|
|
|
60
81
|
let code: TaskDiagnosticCode;
|
|
@@ -78,8 +99,16 @@ export function diagnoseTask(input: TaskDiagnosticInput): TaskDiagnostic {
|
|
|
78
99
|
code = "target_no_live_sse";
|
|
79
100
|
action_hint = "restore_sse";
|
|
80
101
|
} else if (input.status === "acked" || input.status === "running") {
|
|
81
|
-
|
|
82
|
-
action_hint
|
|
102
|
+
// 🔴 #1083:这里原本只有一档,于是【还没走到】和【不会走到】共用同一个 code
|
|
103
|
+
// 和同一个 action_hint(`inspect_runtime`)。后者被支去查一个没坏的东西。
|
|
104
|
+
// 只有在 Hub **确知**该目标从未报过证据时才改判;`null`(没算) 保持旧行为。
|
|
105
|
+
if (input.targetEverReportedRuntimeEvidence === false) {
|
|
106
|
+
code = "runtime_evidence_channel_absent";
|
|
107
|
+
action_hint = "wire_runtime_evidence";
|
|
108
|
+
} else {
|
|
109
|
+
code = "lifecycle_progress_without_runtime_evidence";
|
|
110
|
+
action_hint = "inspect_runtime";
|
|
111
|
+
}
|
|
83
112
|
} else {
|
|
84
113
|
code = "delivered_waiting_for_agent";
|
|
85
114
|
action_hint = "inspect_agent_queue";
|
|
@@ -114,6 +114,11 @@ describe("send_task durable idempotency", () => {
|
|
|
114
114
|
task_id: first.message_id,
|
|
115
115
|
task_status: "delivered",
|
|
116
116
|
idempotent_replay: true,
|
|
117
|
+
actual_to: {
|
|
118
|
+
alias: TARGET,
|
|
119
|
+
to_node_id: "id_node_chat_idem",
|
|
120
|
+
network_id: NET,
|
|
121
|
+
},
|
|
117
122
|
});
|
|
118
123
|
expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM tasks WHERE network_id = ?1", [NET])?.count).toBe(1);
|
|
119
124
|
expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM inbox WHERE network_id = ?1", [NET])?.count).toBe(1);
|
|
@@ -45,6 +45,26 @@ afterAll(() => {
|
|
|
45
45
|
try { server?.stop(true); } catch {}
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* 轮询到 `ready()` 为真,或到期抛错。
|
|
50
|
+
*
|
|
51
|
+
* 定长 sleep 的问题不是「慢」,是**报错报在错的层**:超时之后测试红在
|
|
52
|
+
* 「事件没写」这条断言上,读的人会去查 watcher,而真实原因可能是子进程
|
|
53
|
+
* 还没起来。这里到期时把那句话直接说出来。
|
|
54
|
+
*/
|
|
55
|
+
async function waitUntil(ready: () => boolean, timeoutMs: number, what: string | null): Promise<void> {
|
|
56
|
+
const deadline = Date.now() + timeoutMs;
|
|
57
|
+
for (;;) {
|
|
58
|
+
if (ready()) return;
|
|
59
|
+
if (Date.now() >= deadline) {
|
|
60
|
+
// what === null:到期本身就是期望结果(调用方随后自己断言),不抛。
|
|
61
|
+
if (what === null) return;
|
|
62
|
+
throw new Error(`timed out after ${timeoutMs}ms: ${what}`);
|
|
63
|
+
}
|
|
64
|
+
await Bun.sleep(25);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
48
68
|
describe("#167 Hub delivered-stale lifecycle watcher", () => {
|
|
49
69
|
test("30s/60s thresholds are exact and non-delivered tasks stay silent", () => {
|
|
50
70
|
const first = recordDeliveredStaleEvents(NOW);
|
|
@@ -132,12 +152,23 @@ describe("#167 Hub delivered-stale lifecycle watcher", () => {
|
|
|
132
152
|
stdout: "pipe",
|
|
133
153
|
stderr: "pipe",
|
|
134
154
|
});
|
|
135
|
-
|
|
155
|
+
// 这里**不**等「hub 就绪」——因为没有一个只有 hub 起来了才成立的廉价判据:
|
|
156
|
+
// db 文件在上面那步 init 里就已经存在了,拿它当条件的话这个等待恒真,等于没等。
|
|
157
|
+
// 真正需要「hub 起来了」的是下面那条断言,而它已经改成轮询到目标状态,
|
|
158
|
+
// hub 起得慢只是让它多等几轮。
|
|
159
|
+
//
|
|
160
|
+
// 这一步保留的是原来那条断言的原意:**子进程没有立刻崩**。所以只给它一个
|
|
161
|
+
// 短窗口,并且如果它在窗口内退出就立刻停下来报错,不用把 800ms 睡满。
|
|
162
|
+
await waitUntil(() => child.exitCode !== null, 800, null);
|
|
136
163
|
expect(child.exitCode).toBeNull();
|
|
137
164
|
|
|
138
165
|
const taskId = "stale-live-wiring";
|
|
139
166
|
const childDb = new Database(childDbPath);
|
|
140
167
|
try {
|
|
168
|
+
// startHub may still be finishing its startup migration transaction even
|
|
169
|
+
// though the process has stayed alive. Wait for that short-lived writer
|
|
170
|
+
// instead of turning scheduler timing into an immediate SQLITE_BUSY.
|
|
171
|
+
childDb.exec("PRAGMA busy_timeout = 5000");
|
|
141
172
|
childDb.run(
|
|
142
173
|
`INSERT INTO tasks
|
|
143
174
|
(task_id, from_name, to_name, status, content, delivered_at, network_id)
|
|
@@ -147,14 +178,23 @@ describe("#167 Hub delivered-stale lifecycle watcher", () => {
|
|
|
147
178
|
expect(childDb.query<{ count: number }, [string]>(
|
|
148
179
|
"SELECT COUNT(*) AS count FROM task_events WHERE task_id = ?1",
|
|
149
180
|
).get(taskId)!.count).toBe(0);
|
|
150
|
-
|
|
151
|
-
|
|
181
|
+
// 巡检周期是 COMMHUB_DELIVERED_STALE_PATROL_MS=25ms —— 事件在插入后
|
|
182
|
+
// 几十毫秒内就该出现。原来这里是定长 sleep(3_200),纯粹是余量:
|
|
183
|
+
// 4.0s 的 sleep 装在 bun 默认的 5.0s 单测预算里,只剩 1s 给两次进程启动。
|
|
184
|
+
// 实测在 CI 上被这一条打红过(#798 让这个文件第一次进 CI 才暴露)。
|
|
185
|
+
// 改成「轮询到目标状态,或到期报错」:常态下快 ~60 倍,慢的时候等得起。
|
|
186
|
+
const countStale = () => childDb.query<{ count: number }, [string]>(
|
|
152
187
|
"SELECT COUNT(*) AS count FROM task_events WHERE task_id = ?1 AND event_type = 'task.warning.delivered_stale_30s'",
|
|
153
|
-
).get(taskId)!.count
|
|
188
|
+
).get(taskId)!.count;
|
|
189
|
+
await waitUntil(() => countStale() === 1, 20_000,
|
|
190
|
+
"watcher did not write the delivered_stale_30s event");
|
|
191
|
+
expect(countStale()).toBe(1);
|
|
154
192
|
} finally {
|
|
155
193
|
childDb.close();
|
|
156
194
|
try { child.kill("SIGTERM"); } catch {}
|
|
157
195
|
await child.exited;
|
|
158
196
|
}
|
|
159
|
-
|
|
197
|
+
// 🔴 显式超时:这一条要起两个真 bun 进程,bun 默认的 5s 对它不成立。
|
|
198
|
+
// 上面两处已改成轮询,常态用不到这个上限;它只保证「慢」不会被报成「坏」。
|
|
199
|
+
}, 30_000);
|
|
160
200
|
});
|
package/src/tools.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { z } from "zod/v4";
|
|
3
|
+
import { parseAliasFilter } from "./alias-filter.js";
|
|
3
4
|
import { createHash } from "node:crypto";
|
|
4
5
|
import { db, uuidv4, logTaskEvent, chainReplyToParent, hashToken, generateId, generateNetworkToken, syncScheduledRunForTask } from "./db.js";
|
|
5
6
|
import { getSSEStats, pushEvent, pushNetworkObserverEvent } from "./push.js";
|
|
@@ -363,6 +364,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
363
364
|
| { state: "offline"; alias: string; session: any; message: string }
|
|
364
365
|
| { state: "not_found"; alias: string; message: string };
|
|
365
366
|
|
|
367
|
+
// Only call after the authenticated, network-scoped lookup resolved a
|
|
368
|
+
// concrete target. Error paths intentionally omit this identity object.
|
|
369
|
+
const actualTo = (target: Exclude<DeliveryTarget, { state: "not_found" }>, networkId?: string | null) => ({
|
|
370
|
+
alias: target.alias,
|
|
371
|
+
to_node_id: target.session?.node_id ?? null,
|
|
372
|
+
network_id: networkId ?? null,
|
|
373
|
+
});
|
|
374
|
+
|
|
366
375
|
const scopedSessionStatus = (alias: string, networkId?: string | null) => {
|
|
367
376
|
const params: any[] = [alias];
|
|
368
377
|
let sql = "SELECT status, updated_at, last_seen_at, node_id FROM sessions WHERE alias = ?1";
|
|
@@ -1104,16 +1113,25 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1104
1113
|
|
|
1105
1114
|
server.tool(
|
|
1106
1115
|
"get_all_status",
|
|
1107
|
-
"Get status of all sessions. Hub uses this for the patrol loop."
|
|
1116
|
+
"Get status of all sessions. Hub uses this for the patrol loop. " +
|
|
1117
|
+
"Pass filter_alias (comma-separated) when you only care about specific " +
|
|
1118
|
+
"nodes — the unfiltered result is one row per session with 31 columns and " +
|
|
1119
|
+
"is large enough on a real fleet that callers cannot read it.",
|
|
1108
1120
|
{
|
|
1109
1121
|
filter_status: z.string().max(50).optional(),
|
|
1110
1122
|
filter_server: z.string().max(200).optional(),
|
|
1123
|
+
// 2026-08-17: on a 222-session hub the unfiltered response is ~259 KB, past
|
|
1124
|
+
// what an MCP client can take in one result — so the caller that wanted the
|
|
1125
|
+
// status of THREE nodes could not get it from this tool at all. The patrol
|
|
1126
|
+
// loop still wants everything, hence optional rather than required.
|
|
1127
|
+
filter_alias: z.string().max(2000).optional()
|
|
1128
|
+
.describe("One alias, or several separated by commas. Exact matches only."),
|
|
1111
1129
|
network_id: z.string().max(200).optional().describe("Filter by network"),
|
|
1112
1130
|
},
|
|
1113
|
-
async ({ filter_status, filter_server, network_id: netId }) => {
|
|
1131
|
+
async ({ filter_status, filter_server, filter_alias, network_id: netId }) => {
|
|
1114
1132
|
const readScope = resolveReadScope(netId);
|
|
1115
1133
|
if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
|
|
1116
|
-
console.log(`[${ts()}] hub → get_all_status${filter_status ? ": filter=" + filter_status : ""}${readScope.networkId ? " net=" + readScope.networkId.slice(0, 12) : ""}`);
|
|
1134
|
+
console.log(`[${ts()}] hub → get_all_status${filter_status ? ": filter=" + filter_status : ""}${filter_alias ? " alias=" + filter_alias.slice(0, 80) : ""}${readScope.networkId ? " net=" + readScope.networkId.slice(0, 12) : ""}`);
|
|
1117
1135
|
|
|
1118
1136
|
// Round-2/4 review ③: stale-marking moved to startStaleSessionSweeper()
|
|
1119
1137
|
// (background timer, ~60s cadence). Read path no longer fires UPDATE.
|
|
@@ -1122,20 +1140,42 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1122
1140
|
sql = addReadScope(sql, params, readScope);
|
|
1123
1141
|
if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
|
|
1124
1142
|
if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
|
|
1143
|
+
const aliasFilter = parseAliasFilter(filter_alias);
|
|
1144
|
+
const aliases = aliasFilter.aliases;
|
|
1145
|
+
if (aliasFilter.sql) {
|
|
1146
|
+
sql += aliasFilter.sql;
|
|
1147
|
+
params.push(...aliases);
|
|
1148
|
+
}
|
|
1125
1149
|
sql += " ORDER BY updated_at DESC";
|
|
1126
1150
|
const sessions = db.all(sql, ...params);
|
|
1127
1151
|
|
|
1152
|
+
// `summary` has always counted every session in the read scope, ignoring
|
|
1153
|
+
// filter_status / filter_server — and now filter_alias. That is fine for
|
|
1154
|
+
// the patrol loop, but a caller who asked about three aliases and gets
|
|
1155
|
+
// back three rows plus "idle: 96" can easily read the 96 as being about
|
|
1156
|
+
// their three. A count that does not say what it counted invites exactly
|
|
1157
|
+
// that. So the response now says so, rather than the semantics changing
|
|
1158
|
+
// under existing callers.
|
|
1128
1159
|
const summaryParams: any[] = [];
|
|
1129
1160
|
let summarySql = "SELECT status, COUNT(*) as count FROM sessions WHERE 1=1";
|
|
1130
1161
|
summarySql = addReadScope(summarySql, summaryParams, readScope);
|
|
1131
1162
|
summarySql += " GROUP BY status";
|
|
1132
1163
|
const summary = db.all(summarySql, ...summaryParams);
|
|
1133
1164
|
|
|
1165
|
+
const filtered = !!(filter_status || filter_server || aliases.length > 0);
|
|
1134
1166
|
return {
|
|
1135
1167
|
content: [
|
|
1136
1168
|
{
|
|
1137
1169
|
type: "text" as const,
|
|
1138
|
-
text: JSON.stringify({
|
|
1170
|
+
text: JSON.stringify({
|
|
1171
|
+
ok: true,
|
|
1172
|
+
sessions,
|
|
1173
|
+
summary,
|
|
1174
|
+
summary_scope: filtered
|
|
1175
|
+
? "every session in the read scope — NOT narrowed by the filters applied to `sessions`"
|
|
1176
|
+
: "every session in the read scope",
|
|
1177
|
+
sessions_returned: sessions.length,
|
|
1178
|
+
}),
|
|
1139
1179
|
},
|
|
1140
1180
|
],
|
|
1141
1181
|
};
|
|
@@ -1283,8 +1323,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1283
1323
|
? idempotentTaskId(effectiveNetId ?? null, from_session, clientRequestId)
|
|
1284
1324
|
: uuidv4();
|
|
1285
1325
|
if (clientRequestId) {
|
|
1286
|
-
const existing = db.get<StoredIdempotentTask>(
|
|
1287
|
-
"SELECT task_id, from_name, to_name, priority, content, network_id, meta_json, status FROM tasks WHERE task_id = ?1",
|
|
1326
|
+
const existing = db.get<StoredIdempotentTask & { to_node_id: string | null }>(
|
|
1327
|
+
"SELECT task_id, from_name, to_node_id, to_name, priority, content, network_id, meta_json, status FROM tasks WHERE task_id = ?1",
|
|
1288
1328
|
[id],
|
|
1289
1329
|
);
|
|
1290
1330
|
if (existing) {
|
|
@@ -1300,6 +1340,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1300
1340
|
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
1301
1341
|
ok: true, message_id: existing.task_id, task_id: existing.task_id,
|
|
1302
1342
|
task_status: existing.status, idempotent_replay: true,
|
|
1343
|
+
// A replay acknowledges the already-persisted dispatch. Use that
|
|
1344
|
+
// row rather than today's session record so alias reuse or a
|
|
1345
|
+
// restarted process cannot rewrite historical recipient identity.
|
|
1346
|
+
actual_to: {
|
|
1347
|
+
alias: existing.to_name,
|
|
1348
|
+
to_node_id: existing.to_node_id ?? null,
|
|
1349
|
+
network_id: existing.network_id ?? null,
|
|
1350
|
+
},
|
|
1303
1351
|
}) }] };
|
|
1304
1352
|
}
|
|
1305
1353
|
}
|
|
@@ -1333,6 +1381,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1333
1381
|
console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
|
|
1334
1382
|
const fromNodeId = resolveNodeIdForAlias(from_session, effectiveNetId);
|
|
1335
1383
|
const targetNodeId = target.session?.node_id ?? null;
|
|
1384
|
+
const resolvedActualTo = actualTo(target, effectiveNetId);
|
|
1336
1385
|
// 事务:inbox + tasks 双写 + 触碰目标 session 的 task/updated_at(让
|
|
1337
1386
|
// dashboard 在派任务一刻就反映出"任务已下发",不再等 agent 的
|
|
1338
1387
|
// report_status 心跳;status 字段交给 agent,避免与 working/idle
|
|
@@ -1390,6 +1439,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1390
1439
|
task_id: id,
|
|
1391
1440
|
message_id: id,
|
|
1392
1441
|
session_status: target.session.status ?? "offline",
|
|
1442
|
+
actual_to: resolvedActualTo,
|
|
1393
1443
|
...(canonical.renamed ? { renamed_from: alias, renamed_to: targetAlias } : {}),
|
|
1394
1444
|
}),
|
|
1395
1445
|
}],
|
|
@@ -1403,6 +1453,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1403
1453
|
text: JSON.stringify({
|
|
1404
1454
|
ok: true,
|
|
1405
1455
|
message_id: id,
|
|
1456
|
+
actual_to: resolvedActualTo,
|
|
1406
1457
|
...(canonical.renamed ? { renamed_from: alias, renamed_to: targetAlias } : {}),
|
|
1407
1458
|
session_status: target.session?.status ?? "unknown",
|
|
1408
1459
|
}),
|
|
@@ -1908,11 +1959,52 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1908
1959
|
from_name: z.string().max(200).optional().describe("Filter by sender"),
|
|
1909
1960
|
from_node_id: z.string().max(200).optional().describe("Filter by immutable sender node_id"),
|
|
1910
1961
|
network_id: z.string().max(200).optional().describe("Filter by network"),
|
|
1962
|
+
before_created_at: z.string().max(64).optional().describe("Pagination cursor timestamp"),
|
|
1963
|
+
before_task_id: z.string().max(200).optional().describe("Pagination cursor task id"),
|
|
1964
|
+
durable_cursor: z.boolean().optional().describe("Require immutable node-scoped cursor protocol"),
|
|
1965
|
+
durable_terminal_cursor: z.boolean().optional().describe("Read terminal outbound journal in monotonic order"),
|
|
1966
|
+
after_terminal_seq: z.number().int().min(0).optional().describe("Exclusive terminal journal watermark"),
|
|
1911
1967
|
limit: z.number().min(1).max(100).optional().default(20),
|
|
1912
1968
|
},
|
|
1913
|
-
async ({ alias, status, from_name, from_node_id, network_id: netId, limit }) => {
|
|
1969
|
+
async ({ alias, status, from_name, from_node_id, network_id: netId, before_created_at, before_task_id, durable_cursor, durable_terminal_cursor, after_terminal_seq, limit }) => {
|
|
1914
1970
|
const readScope = resolveReadScope(netId);
|
|
1915
1971
|
if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
|
|
1972
|
+
if (durable_cursor) {
|
|
1973
|
+
if (!callerTokenIsNetwork) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_token_required" }) }] };
|
|
1974
|
+
const boundNodeId = callerTokenId && enforceNetworkId
|
|
1975
|
+
? db.get<{ bound_node_id: string | null }>(
|
|
1976
|
+
"SELECT bound_node_id FROM api_tokens WHERE token_id = ?1 AND network_id = ?2",
|
|
1977
|
+
callerTokenId,
|
|
1978
|
+
enforceNetworkId,
|
|
1979
|
+
)?.bound_node_id ?? null
|
|
1980
|
+
: null;
|
|
1981
|
+
if (!boundNodeId || from_node_id !== boundNodeId) {
|
|
1982
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "from_node_id_identity_mismatch" }) }] };
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
if (durable_terminal_cursor) {
|
|
1986
|
+
if (!durable_cursor) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "durable_cursor_required" }) }] };
|
|
1987
|
+
const params: any[] = [enforceNetworkId, from_node_id, after_terminal_seq ?? 0, limit];
|
|
1988
|
+
const tasks = db.all(
|
|
1989
|
+
`SELECT t.task_id, t.from_node_id, t.from_name, t.to_node_id, t.to_name, t.priority,
|
|
1990
|
+
t.status, t.content, t.result, t.created_at, t.runtime_submitted_at,
|
|
1991
|
+
t.consumed_at, t.completed_at, e.terminal_seq
|
|
1992
|
+
FROM task_terminal_events e
|
|
1993
|
+
JOIN tasks t ON t.task_id = e.task_id
|
|
1994
|
+
WHERE COALESCE(e.network_id, 'default') = COALESCE(?1, 'default')
|
|
1995
|
+
AND e.from_node_id = ?2 AND e.terminal_seq > ?3
|
|
1996
|
+
ORDER BY e.terminal_seq ASC LIMIT ?4`,
|
|
1997
|
+
...params,
|
|
1998
|
+
);
|
|
1999
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
2000
|
+
ok: true,
|
|
2001
|
+
capability: "list_tasks.immutable-terminal-sequence.v2",
|
|
2002
|
+
tasks,
|
|
2003
|
+
count: tasks.length,
|
|
2004
|
+
next_terminal_seq: tasks.length ? tasks[tasks.length - 1]?.terminal_seq : (after_terminal_seq ?? 0),
|
|
2005
|
+
has_more: tasks.length === limit,
|
|
2006
|
+
}) }] };
|
|
2007
|
+
}
|
|
1916
2008
|
let sql = "SELECT task_id, from_node_id, from_name, to_node_id, to_name, priority, status, content, result, created_at, runtime_submitted_at, consumed_at, completed_at FROM tasks WHERE 1=1";
|
|
1917
2009
|
const params: any[] = [];
|
|
1918
2010
|
sql = addReadScope(sql, params, readScope);
|
|
@@ -1920,7 +2012,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1920
2012
|
if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
|
|
1921
2013
|
if (from_name) { sql += ` AND from_name = ?${params.length + 1}`; params.push(from_name); }
|
|
1922
2014
|
if (from_node_id) { sql += ` AND from_node_id = ?${params.length + 1}`; params.push(from_node_id); }
|
|
1923
|
-
|
|
2015
|
+
if (before_created_at && before_task_id) {
|
|
2016
|
+
sql += ` AND (created_at < ?${params.length + 1} OR (created_at = ?${params.length + 1} AND task_id < ?${params.length + 2}))`;
|
|
2017
|
+
params.push(before_created_at, before_task_id);
|
|
2018
|
+
}
|
|
2019
|
+
sql += ` ORDER BY created_at DESC, task_id DESC LIMIT ?${params.length + 1}`;
|
|
1924
2020
|
params.push(limit);
|
|
1925
2021
|
const tasks = db.all(sql, ...params);
|
|
1926
2022
|
|
|
@@ -1934,7 +2030,17 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1934
2030
|
return {
|
|
1935
2031
|
content: [{
|
|
1936
2032
|
type: "text" as const,
|
|
1937
|
-
text: JSON.stringify({
|
|
2033
|
+
text: JSON.stringify({
|
|
2034
|
+
ok: true,
|
|
2035
|
+
...(durable_cursor ? { capability: "list_tasks.immutable-node-cursor.v1" } : {}),
|
|
2036
|
+
tasks,
|
|
2037
|
+
count: tasks.length,
|
|
2038
|
+
next_cursor: tasks.length === limit ? {
|
|
2039
|
+
before_created_at: tasks[tasks.length - 1]?.created_at,
|
|
2040
|
+
before_task_id: tasks[tasks.length - 1]?.task_id,
|
|
2041
|
+
} : null,
|
|
2042
|
+
stats,
|
|
2043
|
+
}),
|
|
1938
2044
|
}],
|
|
1939
2045
|
};
|
|
1940
2046
|
}
|