@sleep2agi/commhub-server 0.9.0-preview.113 → 0.9.0-preview.115

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.
@@ -0,0 +1,54 @@
1
+ import type { DbAdapter } from "./db-adapter";
2
+
3
+ type StoredLateReply = {
4
+ late_reply_id: string;
5
+ task_id: string;
6
+ from_node_id: string;
7
+ thread_id: string;
8
+ turn_id: string;
9
+ status: "replied" | "failed" | "cancelled";
10
+ result: string;
11
+ meta_json: string | null;
12
+ created_at: string;
13
+ };
14
+
15
+ function parsedMeta(raw: string | null): Record<string, unknown> | null {
16
+ if (!raw) return null;
17
+ try {
18
+ const value = JSON.parse(raw);
19
+ return value && typeof value === "object" && !Array.isArray(value)
20
+ ? value as Record<string, unknown>
21
+ : null;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ /** Stable detail projection. Storage routing ids stay private. */
28
+ export function listTaskLateReplies(database: DbAdapter, taskId: string, networkId: string | null) {
29
+ const params: unknown[] = [taskId];
30
+ let sql = `SELECT late_reply_id,task_id,from_node_id,thread_id,turn_id,status,result,meta_json,created_at
31
+ FROM task_late_replies WHERE task_id=?1`;
32
+ if (networkId) {
33
+ params.push(networkId);
34
+ sql += " AND network_id=?2";
35
+ } else {
36
+ sql += " AND network_id IS NULL";
37
+ }
38
+ sql += " ORDER BY created_at,late_reply_id";
39
+ return database.all<StoredLateReply>(sql, ...params).map((row) => {
40
+ const meta = parsedMeta(row.meta_json);
41
+ return {
42
+ late_reply_id: row.late_reply_id,
43
+ task_id: row.task_id,
44
+ from_node_id: row.from_node_id,
45
+ thread_id: row.thread_id,
46
+ turn_id: row.turn_id,
47
+ status: row.status,
48
+ result: row.result,
49
+ created_at: row.created_at,
50
+ late: true as const,
51
+ ...(meta ? { meta } : {}),
52
+ };
53
+ });
54
+ }
@@ -0,0 +1,152 @@
1
+ import { afterAll, beforeAll, expect, test } from "bun:test";
2
+ import { mkdtempSync } from "node:fs";
3
+
4
+ process.env.COMMHUB_DB ||= `${mkdtempSync("/tmp/late710-")}/hub.db`;
5
+
6
+ let db: any;
7
+ let hub: ReturnType<typeof Bun.serve>;
8
+ let base = "";
9
+ let owner: any;
10
+ let outsider: any;
11
+ let workerToken = "";
12
+ let siblingToken = "";
13
+ const workerId = "n_late710_worker";
14
+ const siblingId = "n_late710_sibling";
15
+ const taskId = "task_late710_terminal";
16
+ const threadId = "thread_late710_exact";
17
+ const turnId = "turn_late710_exact";
18
+
19
+ async function mcp(token: string, name: string, args: Record<string, unknown>) {
20
+ const response = await fetch(`${base}/mcp`, {
21
+ method: "POST",
22
+ headers: {
23
+ Authorization: `Bearer ${token}`,
24
+ "Content-Type": "application/json",
25
+ Accept: "application/json, text/event-stream",
26
+ "MCP-Protocol-Version": "2025-03-26",
27
+ },
28
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
29
+ });
30
+ expect(response.status).toBe(200);
31
+ const raw = await response.text();
32
+ const events = raw.split("\n").filter((line) => line.startsWith("data:"));
33
+ const envelope = JSON.parse(events.length ? events.at(-1)!.slice(5) : raw);
34
+ expect(envelope.error).toBeUndefined();
35
+ return JSON.parse(envelope.result.content[0].text);
36
+ }
37
+
38
+ async function get(token: string, path: string) {
39
+ const response = await fetch(`${base}${path}`, { headers: { Authorization: `Bearer ${token}` } });
40
+ return { status: response.status, body: await response.json() as any };
41
+ }
42
+
43
+ beforeAll(async () => {
44
+ ({ db } = await import("./db.js"));
45
+ const { register } = await import("./auth.js");
46
+ const { generateNetworkToken, hashToken } = await import("./db.js");
47
+ owner = register("late710_owner", "Fixture-Strong1!");
48
+ outsider = register("late710_outside", "Fixture-Strong1!");
49
+ expect(owner.ok && outsider.ok).toBe(true);
50
+
51
+ const addNode = (nodeId: string, alias: string) => {
52
+ db.run(`INSERT INTO nodes(node_id,node_name,alias,network_id,owner_user_id,lifecycle_state)
53
+ VALUES(?1,?2,?2,?3,?4,'active')`, [nodeId, alias, owner.network_id, owner.user.user_id]);
54
+ db.run(`INSERT INTO sessions(resume_id,alias,node_id,network_id,status,last_seen_at)
55
+ VALUES(?1,?2,?1,?3,'idle',datetime('now'))`, [nodeId, alias, owner.network_id]);
56
+ const raw = generateNetworkToken();
57
+ db.run(`INSERT INTO api_tokens(token_id,user_id,network_id,scope,name,token_hash,bound_node_id,node_identity_epoch)
58
+ VALUES(?1,?2,?3,'network',?4,?5,?1,2)`, [nodeId, owner.user.user_id, owner.network_id, `node:${alias}`, hashToken(raw)]);
59
+ return raw;
60
+ };
61
+ workerToken = addNode(workerId, "late710-worker");
62
+ siblingToken = addNode(siblingId, "late710-sibling");
63
+ db.run(`INSERT INTO tasks
64
+ (task_id,from_name,to_name,to_node_id,status,content,result,network_id,thread_id,turn_id,completed_at)
65
+ VALUES(?1,?2,'late710-worker',?3,'failed','original task','watchdog interrupted',?4,?5,?6,datetime('now'))`,
66
+ [taskId, owner.user.username, workerId, owner.network_id, threadId, turnId]);
67
+
68
+ const { bootServer } = await import("./server.js");
69
+ hub = bootServer({ port: 0, hostname: "127.0.0.1" });
70
+ base = `http://127.0.0.1:${hub.port}`;
71
+ }, 60_000);
72
+
73
+ afterAll(() => hub?.stop(true));
74
+
75
+ test("exact executor and exact turn append one late receipt without rewriting terminal task", async () => {
76
+ const before = db.get("SELECT status,result,completed_at FROM tasks WHERE task_id=?1", taskId);
77
+ const accepted = await mcp(workerToken, "send_reply", {
78
+ in_reply_to: taskId,
79
+ text: "recovered final result",
80
+ status: "replied",
81
+ thread_id: threadId,
82
+ turn_id: turnId,
83
+ });
84
+ expect(accepted).toMatchObject({ ok: true, late: true, duplicate: false });
85
+ expect(db.get("SELECT status,result,completed_at FROM tasks WHERE task_id=?1", taskId)).toEqual(before);
86
+ expect(db.get("SELECT COUNT(*) AS n FROM task_events WHERE task_id=?1", taskId).n).toBe(0);
87
+
88
+ const detail = await get(owner.token, `/api/tasks/${taskId}`);
89
+ expect(detail.status).toBe(200);
90
+ expect(detail.body.task.late_replies).toEqual([expect.objectContaining({
91
+ late: true, task_id: taskId, from_node_id: workerId,
92
+ thread_id: threadId, turn_id: turnId, status: "replied", result: "recovered final result",
93
+ })]);
94
+ const legacyDetail = await get(owner.token, `/api/tasks?task_id=${taskId}&limit=1&network_id=${owner.network_id}`);
95
+ expect(legacyDetail.body.tasks[0].late_replies).toEqual(detail.body.task.late_replies);
96
+ const mcpDetail = await mcp(owner.token, "get_task", { task_id: taskId });
97
+ expect(mcpDetail.task.late_replies).toEqual(detail.body.task.late_replies);
98
+
99
+ const messages = await get(owner.token, `/api/messages?alias=${encodeURIComponent(owner.user.username)}&since=2000-01-01%2000:00:00`);
100
+ expect(messages.body.messages).toContainEqual(expect.objectContaining({ id: accepted.message_id, late: true, content: "recovered final result" }));
101
+ const inbox = await mcp(owner.token, "get_inbox", { alias: owner.user.username, limit: 100 });
102
+ expect(inbox.messages).toContainEqual(expect.objectContaining({ id: accepted.message_id, late: true, content: "recovered final result" }));
103
+ });
104
+
105
+ test("exact retry is idempotent and a changed payload conflicts", async () => {
106
+ const retry = await mcp(workerToken, "send_reply", {
107
+ in_reply_to: taskId, text: "recovered final result", status: "replied", thread_id: threadId, turn_id: turnId,
108
+ });
109
+ expect(retry).toMatchObject({ ok: true, late: true, duplicate: true });
110
+ expect(db.get("SELECT COUNT(*) AS n FROM task_late_replies WHERE task_id=?1", taskId).n).toBe(1);
111
+ expect(db.get("SELECT COUNT(*) AS n FROM inbox WHERE in_reply_to=?1 AND content='recovered final result'", taskId).n).toBe(1);
112
+ expect(await mcp(workerToken, "send_reply", {
113
+ in_reply_to: taskId, text: "different result", status: "replied", thread_id: threadId, turn_id: turnId,
114
+ })).toMatchObject({ ok: false, error: "late_reply_conflict" });
115
+ });
116
+
117
+ test("wrong executor, sibling turn, cross-network reader and legacy shape cannot append", async () => {
118
+ const count = () => db.get("SELECT COUNT(*) AS n FROM task_late_replies").n;
119
+ const before = count();
120
+ expect(await mcp(siblingToken, "send_reply", {
121
+ in_reply_to: taskId, text: "impostor", thread_id: threadId, turn_id: turnId,
122
+ })).toMatchObject({ ok: false, error: "late_reply_task_not_owned" });
123
+ expect(await mcp(workerToken, "send_reply", {
124
+ in_reply_to: taskId, text: "wrong turn", thread_id: threadId, turn_id: "turn_late710_sibling",
125
+ })).toMatchObject({ ok: false, error: "late_reply_context_mismatch" });
126
+ expect(await mcp(workerToken, "send_reply", {
127
+ in_reply_to: taskId, text: "legacy terminal reply",
128
+ })).toMatchObject({ ok: false, error: "reply_task_terminal" });
129
+ expect(await mcp(owner.token, "send_reply", {
130
+ in_reply_to: taskId, text: "human cannot forge runtime evidence", thread_id: threadId, turn_id: turnId,
131
+ })).toMatchObject({ ok: false, error: "late_reply_node_token_required" });
132
+ // Length bound is enforced in the handler (kept out of tools/list for #476).
133
+ for (const bad of ["", "x".repeat(201)]) {
134
+ expect(await mcp(workerToken, "send_reply", {
135
+ in_reply_to: taskId, text: "bad identity", thread_id: bad, turn_id: turnId,
136
+ })).toMatchObject({ ok: false, error: "late_reply_context_invalid" });
137
+ }
138
+ expect(count()).toBe(before);
139
+ expect((await get(outsider.token, `/api/tasks/${taskId}`)).status).toBe(404);
140
+ expect((await mcp(outsider.token, "get_task", { task_id: taskId })).ok).toBe(false);
141
+ });
142
+
143
+ test("nonterminal legacy reply retains its established behavior", async () => {
144
+ const openId = "task_late710_open";
145
+ db.run(`INSERT INTO tasks(task_id,from_name,to_name,to_node_id,status,content,network_id)
146
+ VALUES(?1,?2,'late710-worker',?3,'running','legacy open',?4)`, [openId, owner.user.username, workerId, owner.network_id]);
147
+ const result = await mcp(workerToken, "send_reply", { in_reply_to: openId, text: "ordinary final" });
148
+ expect(result).toMatchObject({ ok: true });
149
+ expect(result.late).toBeUndefined();
150
+ expect(db.get("SELECT status,result FROM tasks WHERE task_id=?1", openId)).toEqual({ status: "replied", result: "ordinary final" });
151
+ expect(db.get("SELECT COUNT(*) AS n FROM task_late_replies WHERE task_id=?1", openId).n).toBe(0);
152
+ });
@@ -184,7 +184,14 @@ describe("bytes per role (#476 ceilings stay meaningful per role)", () => {
184
184
  // 量出来(#478 合入时):全部 74 个工具 71,559 B;节点 52 个 57,632 B(−19%);人 56 个 54,780 B(−23%)。留 ~1.5 KB 余量。
185
185
  // Board #625 adds 2 human tools + 2 hidden daemon protocol tools. Measured:
186
186
  // all 78 / 73,969 B; user 58 / 56,615 B; node remains 52 / 58,320 B.
187
- const CEILINGS = { node: 59_000, user: 57_500, all: 75_000 } as const;
187
+ // Board #733 adds 7 node-only schedule_* tools (user list unchanged). Measured before → after on the same run:
188
+ // node 52 / 58,836 B → 59 / 61,607 B (+2,771 B, ~396 B per tool of which ~180 B is SDK per-tool overhead —
189
+ // $schema + execution — after trimming schemas to bare field names; values are validated server-side);
190
+ // all 78 / 74,485 B → 85 / 77,256 B; user 58 / 56,834 B → 58 / 56,834 B. main had 164 B node headroom left,
191
+ // so any new node tool needs this; ceilings keep the same ~400–750 B margin.
192
+ // Review follow-up: descriptions / schemas cut further → node 59 / 61,100 B, all 85 / 76,749 B (−507 B each),
193
+ // leaving ≥ 700 B node margin even with #2488 (+116 B) merged.
194
+ const CEILINGS = { node: 62_000, user: 57_500, all: 78_000 } as const;
188
195
  test("node / user / all", async () => {
189
196
  const node = await listTools(T.node_normal);
190
197
  const user = await listTools(T.owner);
@@ -27,6 +27,9 @@ export const TOOL_AUDIENCE: Readonly<Record<string, ToolAudience>> = {
27
27
  get_adopt_request: "protocol", ack_adopt_request: "protocol",
28
28
  // ── 节点:用户令牌一律 network_token_required ──
29
29
  report_status: "node",
30
+ // #733 定时任务:节点工具(用户令牌一律 network_token_required;人走 REST /api/scheduled-tasks)
31
+ schedule_list: "node", schedule_get: "node", schedule_create: "node", schedule_update: "node",
32
+ schedule_cancel: "node", schedule_run_now: "node", schedule_runs: "node",
30
33
  // ── 协议:用户令牌一律 network_token_required / node_token_required / caller_not_a_daemon ──
31
34
  mark_tasks_runtime_submitted: "protocol", mark_tasks_consumed: "protocol",
32
35
  get_config_update: "protocol", ack_config_update: "protocol",
package/src/tools.ts CHANGED
@@ -20,6 +20,7 @@ import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
20
20
  import { addAgentNetworkScope, addAgentTimelineScope, addNetworkScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
21
21
  import { canMessageAgent, restrictedNetworkIds, RESTRICTED_MEMBER_TOOLS, type AgentRef } from "./agent-access.js";
22
22
  import { listedToolFilter, scopeToolsList, type ToolCaller } from "./tool-audience.js";
23
+ import { agentCancelSchedule, agentCreateSchedule, agentGetSchedule, agentListSchedules, agentRunScheduleNow, agentScheduleRuns, agentUpdateSchedule } from "./schedule-agent.js";
23
24
  import { broadcastVerdict, dispatchVerdict, humanOnlyVerdict, NODE_TOOL_CLASS, nodeDecide, nodeIdentity, nodePermissionDeniedBody, writeVerdict, type NodeIdentity, type Verdict } from "./node-permissions.js";
24
25
  import { restrictedMemberAttachmentsDenied } from "./restricted-files.js";
25
26
  import { errorBody } from "./requirements-errors.js";
@@ -82,6 +83,7 @@ import { stampTaskAuthOrigin, type TaskAuthOrigin } from "./task-auth-origin.js"
82
83
  import { dispatchQueueInfo, queueDepthByNode, queueDepthKey } from "./task-queue-ahead.js";
83
84
  import { parseHubTimestamp } from "./hub-timestamp";
84
85
  import { noteTerminalResultRead, purgeLogsResultNow, sweepNodeRequestContent } from "./node-request-retention.js";
86
+ import { listTaskLateReplies } from "./task-late-replies.js";
85
87
 
86
88
  function ts(): string {
87
89
  return new Date().toTimeString().slice(0, 8);
@@ -102,6 +104,15 @@ function normalizeMetaJson(meta: unknown): string | null {
102
104
  try { return JSON.stringify(stripHostLocalPathsForCrossHostSafe(meta)); } catch { return null; }
103
105
  }
104
106
 
107
+ function lateReplyContextMatches(
108
+ storedThreadId: string | null,
109
+ storedTurnId: string | null,
110
+ threadId: string,
111
+ turnId: string,
112
+ ): boolean {
113
+ return storedThreadId === threadId && storedTurnId === turnId;
114
+ }
115
+
105
116
  /**
106
117
  * 定时任务的回复收件人。scheduler 派的任务 from_name='scheduler',回复按 from_name 落进
107
118
  * inbox(session_name='scheduler')—— 没有任何人读那一行,于是建排程的人在 app 里看得到回复
@@ -109,7 +120,7 @@ function normalizeMetaJson(meta: unknown): string | null {
109
120
  * 排程是「建它的人」的委托(见 scheduled-tasks.ts canMessageAgent),回复送到他的用户名下。
110
121
  *
111
122
  * 退回 null(= 照旧送 'scheduler')的情形:任务不是 scheduler 派的 / meta 没有排程 id /
112
- * 排程没有 created_by(外部或旧排程)/ 用户已不存在 / 用户名与本网络某个节点 alias 撞名
123
+ * 排程没有 created_by(外部或旧排程)/ 排程是 Agent 建的(#733 created_by_node_id,不往主人的未读里灌)/ 用户已不存在 / 用户名与本网络某个节点 alias 撞名
113
124
  * (那一行会变成那个节点的待办,不是用户的未读 —— 与 server.ts userInboxAliasCollides 同一条规则)。
114
125
  * 只改投递地址;任务归属、reply_target_mismatch 等校验仍按原始发送方 'scheduler' 判。
115
126
  */
@@ -124,7 +135,7 @@ function scheduledReplyRecipient(taskId: string, networkId: string | null | unde
124
135
  if (!scheduleId) return null;
125
136
  const owner = db.get<{ username: string | null }>(
126
137
  `SELECT u.username AS username FROM scheduled_tasks s JOIN users u ON u.user_id = s.created_by
127
- WHERE s.schedule_id = ?1 AND s.network_id = ?2`,
138
+ WHERE s.schedule_id = ?1 AND s.network_id = ?2 AND s.created_by_node_id IS NULL`,
128
139
  scheduleId, task.network_id,
129
140
  );
130
141
  const username = owner?.username?.trim();
@@ -248,7 +259,7 @@ function guardNodePermissionTools(server: McpServer, tokenId: string, networkId:
248
259
  };
249
260
  const wrap = (name: string, handler: (...callArgs: any[]) => any) => async (...callArgs: any[]) => {
250
261
  const cls = NODE_TOOL_CLASS[name];
251
- if (cls && cls !== "always" && cls !== "read" && cls !== "requirements") {
262
+ if (cls && cls !== "always" && cls !== "read" && cls !== "requirements" && cls !== "schedule") {
252
263
  const id = nodeIdentity(tokenId, networkId);
253
264
  const args = callArgs[0] && typeof callArgs[0] === "object" ? callArgs[0] as Record<string, unknown> : {};
254
265
  const verdict = id ? verdictFor(id, name, args) : null;
@@ -1404,16 +1415,18 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1404
1415
  console.log(`[${ts()}] ${alias} → get_inbox: ${rows0?.cnt ?? 0} pending messages`);
1405
1416
  const rowsParams: any[] = [alias];
1406
1417
  let rowsSql = `SELECT id, type, priority, content, context, from_session, created_at, network_id, meta_json,
1418
+ CASE WHEN EXISTS(SELECT 1 FROM task_late_replies lr WHERE lr.inbox_id=inbox.id) THEN 1 ELSE 0 END AS late,
1407
1419
  CASE WHEN type = 'task' THEN COALESCE(task_id, id) ELSE task_id END AS task_id
1408
1420
  FROM inbox WHERE session_name = ?1 AND acked = 0`;
1409
1421
  rowsSql = addReadScope(rowsSql, rowsParams, readScope);
1410
1422
  rowsSql += ` ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, created_at
1411
1423
  LIMIT ?${rowsParams.length + 1}`;
1412
1424
  rowsParams.push(limit);
1413
- const rows = db.all(rowsSql, ...rowsParams).map((row: any) => ({
1414
- ...row,
1415
- meta: parseMetaJson(row.meta_json),
1416
- }));
1425
+ const rows = db.all(rowsSql, ...rowsParams).map((row: any) => {
1426
+ const { late, ...rest } = row;
1427
+ const isLate = late === 1 || late === true;
1428
+ return { ...rest, ...(isLate ? { late: true } : {}), meta: parseMetaJson(row.meta_json) };
1429
+ });
1417
1430
 
1418
1431
  return {
1419
1432
  content: [{ type: "text" as const, text: JSON.stringify({ ok: true, messages: rows }) }],
@@ -2132,15 +2145,26 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2132
2145
  // both `attachments` (top-level) and `meta.attachments` are supplied,
2133
2146
  // top-level wins (same rule as REST /api/task L2101).
2134
2147
  meta: z.any().optional().describe("Optional structured reply metadata, e.g. { attachments: [...] }."),
2148
+ // Exact late-receipt identity. Keep the tools/list schema terse (#476
2149
+ // byte ceiling): the 1..200 length bound is enforced in handleReply,
2150
+ // not advertised; the full contract lives in the task API docs.
2151
+ thread_id: z.string().optional(),
2152
+ turn_id: z.string().optional(),
2135
2153
  };
2136
2154
  const handleReply = async (args: any, peerCapabilityRequired: boolean) => {
2137
- const { alias, text, in_reply_to, status: replyStatus = "replied", from_session: _fromIn, network_id: netId, attachments, meta } = args;
2155
+ const { alias, text, in_reply_to, status: replyStatus = "replied", from_session: _fromIn, network_id: netId, attachments, meta, thread_id: threadId, turn_id: turnId } = args;
2138
2156
  const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
2139
2157
  const effectiveNetId = getNetworkId(netId);
2140
2158
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
2141
2159
  if (peerCapabilityRequired && !in_reply_to) {
2142
2160
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "peer_reply_task_required" }) }] };
2143
2161
  }
2162
+ if ([threadId, turnId].some((v) => v !== undefined && (typeof v !== "string" || v.length < 1 || v.length > 200))) {
2163
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "late_reply_context_invalid", message: "thread_id and turn_id must be 1-200 characters" }) }] };
2164
+ }
2165
+ if ((threadId && !turnId) || (!threadId && turnId)) {
2166
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "late_reply_context_incomplete", message: "thread_id and turn_id must be supplied together" }) }] };
2167
+ }
2144
2168
 
2145
2169
  // #507 — validate attachments BEFORE any DB write. Rejects malformed
2146
2170
  // input (bad file_id, >20 items, size > cap, non-object item, wrong
@@ -2203,6 +2227,97 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2203
2227
  const lc = assertNodeActive(replyDeliveryAlias, effectiveNetId ?? null);
2204
2228
  if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
2205
2229
  }
2230
+
2231
+ // #710 — A recovered runtime turn may finish after its task became
2232
+ // terminal. Preserve that result as an append-only receipt: never
2233
+ // rewrite the task row and never run the parent-task reply chain.
2234
+ if (in_reply_to) {
2235
+ type LateTask = { status: string; from_name: string; to_node_id: string | null; thread_id: string | null; turn_id: string | null };
2236
+ const lateParams: any[] = [in_reply_to];
2237
+ let lateSql = "SELECT status,from_name,to_node_id,thread_id,turn_id FROM tasks WHERE task_id=?1";
2238
+ lateSql = addScope(lateSql, lateParams, effectiveNetId);
2239
+ const lateCandidate = db.get<LateTask>(lateSql, ...lateParams) ?? null;
2240
+ if (lateCandidate && !["created", "delivered", "acked", "running"].includes(lateCandidate.status)) {
2241
+ // Old runtimes do not send turn identity and retain the old result.
2242
+ if (!threadId || !turnId) {
2243
+ return { content: [{ type: "text" as const, text: JSON.stringify({
2244
+ ok: false, error: "reply_task_terminal",
2245
+ message: `cannot apply reply: task is already terminal (${lateCandidate.status})`,
2246
+ in_reply_to, task_status: lateCandidate.status, reply_queued: false,
2247
+ }) }] };
2248
+ }
2249
+ if (!callerTokenIsNetwork || !callerTokenId || !enforceNetworkId) {
2250
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "late_reply_node_token_required", reply_queued: false }) }] };
2251
+ }
2252
+ const token = db.get<{ bound_node_id: string | null }>(
2253
+ "SELECT bound_node_id FROM api_tokens WHERE token_id=?1 AND network_id=?2",
2254
+ callerTokenId, enforceNetworkId,
2255
+ );
2256
+ if (!token?.bound_node_id || !lateCandidate.to_node_id || token.bound_node_id !== lateCandidate.to_node_id) {
2257
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "late_reply_task_not_owned", reply_queued: false }) }] };
2258
+ }
2259
+ if (!lateReplyContextMatches(lateCandidate.thread_id, lateCandidate.turn_id, threadId, turnId)) {
2260
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "late_reply_context_mismatch", reply_queued: false }) }] };
2261
+ }
2262
+ const canonicalOrigin = resolveCanonicalAlias(enforceNetworkId, lateCandidate.from_name).alias;
2263
+ if (replyTargetAlias !== canonicalOrigin) {
2264
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "reply_target_mismatch", reply_queued: false }) }] };
2265
+ }
2266
+
2267
+ const lateOutcome = db.transaction(() => {
2268
+ const currentParams: any[] = [in_reply_to];
2269
+ let currentSql = "SELECT status,to_node_id,thread_id,turn_id FROM tasks WHERE task_id=?1";
2270
+ currentSql = addScope(currentSql, currentParams, effectiveNetId);
2271
+ const current = db.get<{ status: string; to_node_id: string | null; thread_id: string | null; turn_id: string | null }>(currentSql, ...currentParams);
2272
+ if (!current || ["created", "delivered", "acked", "running"].includes(current.status)) {
2273
+ return { ok: false as const, error: "late_reply_task_not_terminal" as const };
2274
+ }
2275
+ if (current.to_node_id !== token.bound_node_id || !lateReplyContextMatches(current.thread_id, current.turn_id, threadId, turnId)) {
2276
+ return { ok: false as const, error: "late_reply_context_mismatch" as const };
2277
+ }
2278
+ const existing = db.get<{ late_reply_id: string; inbox_id: string; from_node_id: string; status: string; result: string }>(
2279
+ "SELECT late_reply_id,inbox_id,from_node_id,status,result FROM task_late_replies WHERE task_id=?1 AND thread_id=?2 AND turn_id=?3",
2280
+ in_reply_to, threadId, turnId,
2281
+ );
2282
+ if (existing) {
2283
+ if (existing.from_node_id !== token.bound_node_id || existing.status !== replyStatus || existing.result !== text) {
2284
+ return { ok: false as const, error: "late_reply_conflict" as const };
2285
+ }
2286
+ return { ok: true as const, duplicate: true as const, lateReplyId: existing.late_reply_id, inboxId: existing.inbox_id };
2287
+ }
2288
+ const lateReplyId = uuidv4();
2289
+ const lateMeta = normalizeMetaJson({
2290
+ ...(mergedMeta && typeof mergedMeta === "object" && !Array.isArray(mergedMeta) ? mergedMeta : {}),
2291
+ late: true, late_reply_id: lateReplyId, thread_id: threadId, turn_id: turnId,
2292
+ });
2293
+ db.run(
2294
+ `INSERT INTO inbox (id,session_name,node_id,type,priority,content,from_session,in_reply_to,requires_response,network_id,meta_json)
2295
+ VALUES (?1,?2,?3,'reply','normal',?4,?5,?6,'none',?7,?8)`,
2296
+ [id, replyDeliveryAlias, replyTargetNodeId, text, from_session, in_reply_to,
2297
+ effectiveNetId ?? null, lateMeta],
2298
+ );
2299
+ db.run(
2300
+ `INSERT INTO task_late_replies
2301
+ (late_reply_id,inbox_id,task_id,network_id,from_node_id,thread_id,turn_id,status,result,meta_json)
2302
+ VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)`,
2303
+ [lateReplyId, id, in_reply_to, effectiveNetId ?? null, token.bound_node_id,
2304
+ threadId, turnId, replyStatus, text, lateMeta],
2305
+ );
2306
+ return { ok: true as const, duplicate: false as const, lateReplyId, inboxId: id };
2307
+ });
2308
+ if (!lateOutcome.ok) {
2309
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: lateOutcome.error, reply_queued: false }) }] };
2310
+ }
2311
+ if (!lateOutcome.duplicate) {
2312
+ pushEvent(replyDeliveryAlias, { type: "new_reply", inbox_count: pendingInboxCount(replyDeliveryAlias, effectiveNetId), from: from_session, message_id: lateOutcome.inboxId, in_reply_to, status: replyStatus, late: true }, effectiveNetId);
2313
+ pushNetworkObserverEvent(effectiveNetId, { type: "new_reply", task_id: in_reply_to, message_id: lateOutcome.inboxId, from: from_session, to: replyDeliveryAlias, status: replyStatus, late: true });
2314
+ }
2315
+ return { content: [{ type: "text" as const, text: JSON.stringify({
2316
+ ok: true, message_id: lateOutcome.inboxId, late_reply_id: lateOutcome.lateReplyId,
2317
+ late: true, duplicate: lateOutcome.duplicate,
2318
+ }) }] };
2319
+ }
2320
+ }
2206
2321
  const replyOutcome = db.transaction(() => {
2207
2322
  type ReplyTask = {
2208
2323
  status: string;
@@ -2581,6 +2696,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2581
2696
  let sql = "SELECT * FROM tasks WHERE task_id = ?1";
2582
2697
  sql = addAgentTimelineScope(sql, params, readScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
2583
2698
  const task = db.get<any>(sql, ...params);
2699
+ if (task) {
2700
+ task.late_replies = listTaskLateReplies(db, task_id, typeof task.network_id === "string" ? task.network_id : null);
2701
+ }
2584
2702
  return {
2585
2703
  content: [{
2586
2704
  type: "text" as const,
@@ -6248,6 +6366,41 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6248
6366
  async ({ id, text, network_id }) => requirementsCall("POST", `/api/requirements/${encodeURIComponent(id)}/comments`, network_id, { text }),
6249
6367
  );
6250
6368
 
6369
+ // Board #733 — Agents manage Hub scheduled tasks. Node tokens only (REST stays people-only); the
6370
+ // permission model lives in schedule-agent.ts.
6371
+ const scheduleCall = (fn: (id: NodeIdentity, canWriteNet: boolean) => Record<string, unknown>) => {
6372
+ let body: Record<string, unknown>;
6373
+ if (!callerTokenIsNetwork || !enforceNetworkId) body = { ok: false, error: "network_token_required" };
6374
+ else {
6375
+ const id = nodeIdentity(callerTokenId, enforceNetworkId);
6376
+ body = id ? fn(id, canWrite(enforceNetworkId)) : { ok: false, error: "node_identity_unbound" };
6377
+ }
6378
+ return { content: [{ type: "text" as const, text: JSON.stringify(body) }] };
6379
+ };
6380
+ // Kept terse on purpose (#476 per-role tools/list ceilings): every value is validated server-side by the
6381
+ // same parseScheduleSpec / validateScheduleCreate as REST, so the schema only names the fields.
6382
+ const schedId = z.string();
6383
+ const schedFields = {
6384
+ name: z.string(), task: z.string(), target_node_id: z.string().optional(),
6385
+ schedule: z.any().describe("once{run_at}|interval{every_seconds>=60}|daily{time}|weekly{time,weekdays}"),
6386
+ timezone: z.string().optional(), priority: z.string().optional(),
6387
+ };
6388
+ server.tool("schedule_list", "Hub schedules for or by you.", { status: z.string().optional() },
6389
+ async ({ status }) => scheduleCall(id => agentListSchedules(id, status)));
6390
+ server.tool("schedule_get", "One schedule.", { schedule_id: schedId },
6391
+ async ({ schedule_id }) => scheduleCall(id => agentGetSchedule(id, schedule_id)));
6392
+ server.tool("schedule_create", "Schedule a task to target_node_id (default you).", schedFields,
6393
+ async (args) => scheduleCall((id, w) => agentCreateSchedule(id, w, args as Record<string, unknown>)));
6394
+ server.tool("schedule_update", "Edit your schedule; status=paused|active.",
6395
+ { ...schedFields, schedule_id: schedId, base_revision: z.number().int().min(0).optional().describe("Reject if current revision differs."), name: z.string().optional(), task: z.string().optional(), schedule: z.any().optional(), status: z.string().optional() },
6396
+ async ({ schedule_id, ...rest }) => scheduleCall((id, w) => agentUpdateSchedule(id, w, schedule_id, rest as Record<string, unknown>)));
6397
+ server.tool("schedule_cancel", "Cancel your schedule.", { schedule_id: schedId },
6398
+ async ({ schedule_id }) => scheduleCall((id, w) => agentCancelSchedule(id, w, schedule_id)));
6399
+ server.tool("schedule_run_now", "Run your schedule now.", { schedule_id: schedId },
6400
+ async ({ schedule_id }) => scheduleCall((id, w) => agentRunScheduleNow(id, w, schedule_id)));
6401
+ server.tool("schedule_runs", "A schedule's recent runs.", { schedule_id: schedId },
6402
+ async ({ schedule_id }) => scheduleCall(id => agentScheduleRuns(id, schedule_id)));
6403
+
6251
6404
  // #478:tools/list 只列这个调用者真能用的工具(tool-audience.ts)。只滤列表,tools/call 不变。
6252
6405
  const caller: ToolCaller = callerTokenIsNetwork
6253
6406
  ? { kind: "node", tokenId: callerTokenId ?? null, networkId: enforceNetworkId ?? null, includeProtocol: listing.includeProtocol === true }