@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.
@@ -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",
@@ -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
- code = "lifecycle_progress_without_runtime_evidence";
82
- action_hint = "inspect_runtime";
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
- await Bun.sleep(800);
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
- await Bun.sleep(3_200);
151
- expect(childDb.query<{ count: number }, [string]>(
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).toBe(1);
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({ ok: true, sessions, summary }),
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
- sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
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({ ok: true, tasks, count: tasks.length, stats }),
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
  }