@sleep2agi/commhub-server 0.9.0-preview.115 → 0.9.0-preview.117
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agent-team-whoami-http.test.ts +98 -0
- package/src/agent-teams-http.test.ts +323 -0
- package/src/agent-teams.ts +244 -0
- package/src/auth.ts +8 -5
- package/src/db.ts +3 -0
- package/src/department-heads-http.test.ts +2 -1
- package/src/node-permissions.ts +1 -1
- package/src/requirements-comment-http.test.ts +2 -0
- package/src/requirements-stats-http.test.ts +1 -1
- package/src/requirements-status-participants-http.test.ts +2 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +9 -5
- package/src/scheduled-offline.test.ts +131 -0
- package/src/scheduled-offline.ts +66 -0
- package/src/scheduled-tasks.ts +33 -4
- package/src/server.ts +61 -4
- package/src/task-access-http.test.ts +38 -15
- package/src/task-access.ts +13 -6
- package/src/task-expiry-notice.ts +9 -7
- package/src/task-orphan-http.test.ts +287 -0
- package/src/task-orphan.ts +119 -0
- package/src/tool-audience.ts +1 -1
- package/src/tools.ts +6 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// #758 —— 「孤儿任务」:任务还停在 acked / running,接收节点却已经回到 idle(或 offline)、
|
|
2
|
+
// 并且在任务开工之后还报过状态 —— 节点转去干别的了,没回这一条。
|
|
3
|
+
//
|
|
4
|
+
// 生产只读实测:17 条任务 acked / running 超过 1 小时,接收节点早已 idle;最长一条(admin 的 P0)挂了 15.7 小时。
|
|
5
|
+
// 以前巡检只对 delivered 记警告(task-lifecycle-watcher.ts),acked / running 这一段谁都看不见。
|
|
6
|
+
//
|
|
7
|
+
// 规则(每次 5 分钟任务巡检跑一遍,server.ts runTaskPatrol):
|
|
8
|
+
// status ∈ {acked, running}
|
|
9
|
+
// ∧ 开工时刻 = COALESCE(started_at, delivered_at, created_at) 早于 COMMHUB_ORPHAN_TASK_MINUTES(默认 60;0 = 关)
|
|
10
|
+
// ∧ 开工时刻在最近 COMMHUB_ORPHAN_TASK_LOOKBACK_HOURS 内(默认 72;0 = 不设上限)—— 首次部署不爆发历史通知
|
|
11
|
+
// ∧ 接收节点(sessions 同网络同别名)当前 status ∈ {idle, offline}
|
|
12
|
+
// ∧ 节点最后一次报状态 COALESCE(last_seen_at, updated_at) 晚于开工时刻
|
|
13
|
+
// 命中后对每条任务**只做一次**:
|
|
14
|
+
// - task_events 写一行 event_type = event_key = task.orphan_suspected(唯一索引 (task_id, event_key) 兜底,
|
|
15
|
+
// ON CONFLICT DO NOTHING;只有真插进去的那一次才往下走)。
|
|
16
|
+
// - 通知派活的一方,与过期通知同一条路(task-expiry-notice.ts 的 classifyExpirySender / deliver):
|
|
17
|
+
// agent → inbox reply + new_reply;人 → user_inbox;scheduler / hub / api → 不发。
|
|
18
|
+
// - **不改任务状态**:不 fail、不关、不碰过期语义。
|
|
19
|
+
|
|
20
|
+
import { db } from "./db.js";
|
|
21
|
+
import { parseDbTimestampMs } from "./db-timestamp.js";
|
|
22
|
+
import { classifyExpirySender, deliver, type ExpiredTaskRow } from "./task-expiry-notice.js";
|
|
23
|
+
|
|
24
|
+
export const ORPHAN_TASK_ENV = "COMMHUB_ORPHAN_TASK_MINUTES";
|
|
25
|
+
export const ORPHAN_DEFAULT_MINUTES = 60;
|
|
26
|
+
export const ORPHAN_LOOKBACK_ENV = "COMMHUB_ORPHAN_TASK_LOOKBACK_HOURS";
|
|
27
|
+
export const ORPHAN_DEFAULT_LOOKBACK_HOURS = 72;
|
|
28
|
+
export const ORPHAN_EVENT_TYPE = "task.orphan_suspected";
|
|
29
|
+
export const ORPHAN_NOTICE_KIND = "task_orphan_suspected";
|
|
30
|
+
const BATCH_LIMIT = 200;
|
|
31
|
+
|
|
32
|
+
/** 未设 / 非数字 → 默认 60;0 或负数 → 关闭(null)。 */
|
|
33
|
+
export function orphanTaskMinutes(env: Record<string, string | undefined> = process.env): number | null {
|
|
34
|
+
const raw = (env[ORPHAN_TASK_ENV] ?? "").trim();
|
|
35
|
+
if (!raw) return ORPHAN_DEFAULT_MINUTES;
|
|
36
|
+
const n = Number(raw);
|
|
37
|
+
if (!Number.isFinite(n)) return ORPHAN_DEFAULT_MINUTES;
|
|
38
|
+
return n > 0 ? n : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 只看开工时刻在最近 N 小时内的任务:未设 / 非数字 / 负数 → 默认 72;0 → 不设上限。
|
|
42
|
+
* 首次部署时不把几周前的历史 acked / running 一次性全标出来、给派活方(包括 owner)刷一屏通知。 */
|
|
43
|
+
export function orphanLookbackHours(env: Record<string, string | undefined> = process.env): number | null {
|
|
44
|
+
const raw = (env[ORPHAN_LOOKBACK_ENV] ?? "").trim();
|
|
45
|
+
const n = raw ? Number(raw) : NaN;
|
|
46
|
+
if (!Number.isFinite(n) || n < 0) return ORPHAN_DEFAULT_LOOKBACK_HOURS;
|
|
47
|
+
return n === 0 ? null : n;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type OrphanRow = ExpiredTaskRow & { status: string; started: string; node_status: string; node_seen: string };
|
|
51
|
+
|
|
52
|
+
export type OrphanFlagged = { task_id: string; notified: "agent" | "user" | null };
|
|
53
|
+
|
|
54
|
+
export function orphanNoticeText(r: Pick<OrphanRow, "task_id" | "to_name" | "status" | "content" | "node_status">, openMin: number | null): { title: string; text: string } {
|
|
55
|
+
const preview = (r.content ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
|
|
56
|
+
const m = openMin === null ? "?" : String(openMin);
|
|
57
|
+
return {
|
|
58
|
+
title: "任务可能没人收尾",
|
|
59
|
+
text: `[Hub] 任务可能没人收尾:你派给 ${r.to_name} 的任务 ${r.task_id} 停在 ${r.status} 已 ${m} 分钟,但 ${r.to_name} 之后已回到 ${r.node_status},一直没有回复。`
|
|
60
|
+
+ `Hub 没有改这条任务的状态。\n内容:${preview}\n`
|
|
61
|
+
+ `建议:问 ${r.to_name} 一句,或 cancel_task 后换个节点重派。\n`
|
|
62
|
+
+ `Task ${r.task_id} to ${r.to_name} has been ${r.status} for ${m} min while ${r.to_name} went ${r.node_status} without replying. Its status was not changed.`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 一次巡检的孤儿阶段。返回本次新标记的任务(测试 / 日志用)。调用方负责吞异常。 */
|
|
67
|
+
export function flagOrphanTasks(opts: { minutes?: number | null; lookbackHours?: number | null; nowMs?: number } = {}): OrphanFlagged[] {
|
|
68
|
+
const minutes = opts.minutes === undefined ? orphanTaskMinutes() : opts.minutes;
|
|
69
|
+
if (!minutes || minutes <= 0) return [];
|
|
70
|
+
const cutoff = `datetime('now', '-${Math.max(1, Math.round(minutes * 60))} seconds')`;
|
|
71
|
+
const started = "COALESCE(t.started_at, t.delivered_at, t.created_at)";
|
|
72
|
+
const lookback = opts.lookbackHours === undefined ? orphanLookbackHours() : opts.lookbackHours;
|
|
73
|
+
const lookbackSql = lookback && lookback > 0
|
|
74
|
+
? `AND ${started} >= datetime('now', '-${Math.max(1, Math.round(lookback * 3600))} seconds')`
|
|
75
|
+
: "";
|
|
76
|
+
const rows = db.all<OrphanRow>(
|
|
77
|
+
`SELECT t.task_id, t.network_id, t.from_name, t.from_node_id, t.to_name, t.content, t.created_at, t.expires_at,
|
|
78
|
+
t.parent_task_id, t.meta_json, t.status, ${started} AS started,
|
|
79
|
+
s.status AS node_status, COALESCE(s.last_seen_at, s.updated_at) AS node_seen
|
|
80
|
+
FROM tasks t
|
|
81
|
+
JOIN sessions s ON s.alias = t.to_name AND s.network_id = COALESCE(t.network_id, 'default')
|
|
82
|
+
WHERE t.status IN ('acked', 'running')
|
|
83
|
+
AND ${started} < ${cutoff}
|
|
84
|
+
${lookbackSql}
|
|
85
|
+
AND s.status IN ('idle', 'offline')
|
|
86
|
+
AND COALESCE(s.last_seen_at, s.updated_at) > ${started}
|
|
87
|
+
AND NOT EXISTS (SELECT 1 FROM task_events e WHERE e.task_id = t.task_id AND e.event_key = '${ORPHAN_EVENT_TYPE}')
|
|
88
|
+
ORDER BY t.created_at ASC LIMIT ${BATCH_LIMIT}`,
|
|
89
|
+
);
|
|
90
|
+
const nowMs = opts.nowMs ?? Date.now();
|
|
91
|
+
const flagged: OrphanFlagged[] = [];
|
|
92
|
+
for (const r of rows) {
|
|
93
|
+
const route = classifyExpirySender(r);
|
|
94
|
+
const who = route.kind === "none" ? `none (${route.reason})` : `${route.kind} ${route.kind === "agent" ? route.alias : route.username}`;
|
|
95
|
+
const detail = `${r.to_name} is ${r.node_status} (last status ${r.node_seen}) since ${r.status} at ${r.started}; notify: ${who}`;
|
|
96
|
+
const res = db.run(
|
|
97
|
+
`INSERT INTO task_events (task_id, from_status, to_status, event_type, event_key, actor, detail, network_id)
|
|
98
|
+
VALUES (?1, ?2, ?2, '${ORPHAN_EVENT_TYPE}', '${ORPHAN_EVENT_TYPE}', 'patrol', ?3, ?4)
|
|
99
|
+
ON CONFLICT(task_id, event_key) DO NOTHING`,
|
|
100
|
+
[r.task_id, r.status, detail, r.network_id ?? null],
|
|
101
|
+
);
|
|
102
|
+
if (res.changes < 1) continue; // 只做一次:别的巡检 / 别的 Hub worker 已经标过了
|
|
103
|
+
let notified: OrphanFlagged["notified"] = null;
|
|
104
|
+
if (route.kind !== "none") {
|
|
105
|
+
try {
|
|
106
|
+
const startedMs = parseDbTimestampMs(r.started);
|
|
107
|
+
const openMin = Number.isFinite(startedMs) ? Math.max(0, Math.round((nowMs - startedMs) / 60_000)) : null;
|
|
108
|
+
const meta = { task_orphan_suspected: { task_id: r.task_id, target: r.to_name, status: r.status, node_status: r.node_status } };
|
|
109
|
+
const id = deliver(route, r.network_id, r.to_name, r.task_id, orphanNoticeText(r, openMin), meta,
|
|
110
|
+
{ kind: ORPHAN_NOTICE_KIND, status: "orphan_suspected", agentPrefix: "orph_", userPrefix: "dm_orphan_" });
|
|
111
|
+
if (id) notified = route.kind;
|
|
112
|
+
} catch (e: any) {
|
|
113
|
+
console.error(`[patrol] orphan notice failed task=${r.task_id}: ${e?.message || e}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
flagged.push({ task_id: r.task_id, notified });
|
|
117
|
+
}
|
|
118
|
+
return flagged;
|
|
119
|
+
}
|
package/src/tool-audience.ts
CHANGED
|
@@ -26,7 +26,7 @@ export const TOOL_AUDIENCE: Readonly<Record<string, ToolAudience>> = {
|
|
|
26
26
|
request_adopt_node: "user", unadopt_node: "user",
|
|
27
27
|
get_adopt_request: "protocol", ack_adopt_request: "protocol",
|
|
28
28
|
// ── 节点:用户令牌一律 network_token_required ──
|
|
29
|
-
report_status: "node",
|
|
29
|
+
report_status: "node", org_whoami: "node",
|
|
30
30
|
// #733 定时任务:节点工具(用户令牌一律 network_token_required;人走 REST /api/scheduled-tasks)
|
|
31
31
|
schedule_list: "node", schedule_get: "node", schedule_create: "node", schedule_update: "node",
|
|
32
32
|
schedule_cancel: "node", schedule_run_now: "node", schedule_runs: "node",
|
package/src/tools.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
import { requestAdopt, getAdopt, ackAdopt, unadopt, resolveManagedDaemon, activeBinding, createdDaemon } from "./node-daemon-bindings.js";
|
|
3
3
|
import { runtimeReadinessSchema } from "./runtime-readiness.js";
|
|
4
4
|
import { lockNodeAlias } from "./node-token-ownership.js";
|
|
5
|
+
import { clearNodeFromAgentTeams, agentTeamWhoami } from "./agent-teams.js";
|
|
5
6
|
import { parseDbTimestampMs } from "./db-timestamp.js";
|
|
6
7
|
import { z } from "zod/v4";
|
|
7
8
|
import { nodeHealthSchema, normalizeNodeHealth, recordNodeHealth } from "./node-health-store.js";
|
|
@@ -5348,6 +5349,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5348
5349
|
);
|
|
5349
5350
|
}
|
|
5350
5351
|
db.run(`DELETE FROM nodes WHERE node_id = ?1`, [row.child_node_id]);
|
|
5352
|
+
clearNodeFromAgentTeams(row.child_node_id); // #764
|
|
5351
5353
|
auditCreateNodeStrict({
|
|
5352
5354
|
action: "delete_node_completed",
|
|
5353
5355
|
user_id: null, network_id: row.network_id, target_id: request_id,
|
|
@@ -6401,6 +6403,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
6401
6403
|
server.tool("schedule_runs", "A schedule's recent runs.", { schedule_id: schedId },
|
|
6402
6404
|
async ({ schedule_id }) => scheduleCall(id => agentScheduleRuns(id, schedule_id)));
|
|
6403
6405
|
|
|
6406
|
+
// #765: exact authenticated node; team tree is independent of human departments.
|
|
6407
|
+
server.tool("org_whoami", "Your Agent team, ancestors, lead, owner and up to 50 teammates. Read-only.", {},
|
|
6408
|
+
async () => scheduleCall(id => agentTeamWhoami(id.networkId, id.nodeId)));
|
|
6409
|
+
|
|
6404
6410
|
// #478:tools/list 只列这个调用者真能用的工具(tool-audience.ts)。只滤列表,tools/call 不变。
|
|
6405
6411
|
const caller: ToolCaller = callerTokenIsNetwork
|
|
6406
6412
|
? { kind: "node", tokenId: callerTokenId ?? null, networkId: enforceNetworkId ?? null, includeProtocol: listing.includeProtocol === true }
|