@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
package/src/scheduled-tasks.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { SCHEDULED_TASK_STORAGE_SELECT } from "./rest-projections.js";
|
|
|
11
11
|
import { parseDbTimestampMs } from "./db-timestamp.js";
|
|
12
12
|
import { blockerTimedOut, expireStuckBlocker, sendStuckNotice, skipsBlockedBy, stuckNoticeSkips, stuckTimeoutMs, type StuckNotice } from "./scheduled-stuck.js";
|
|
13
13
|
import { evaluateScheduleFailures, failureSummary } from "./scheduled-failures.js";
|
|
14
|
+
import { scheduledTargetOffline, sendOfflineNotice, type OfflineNotice } from "./scheduled-offline.js";
|
|
14
15
|
|
|
15
16
|
export type ScheduleSpec =
|
|
16
17
|
| { type: "once"; run_at: string }
|
|
@@ -366,6 +367,7 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
|
|
|
366
367
|
let finalStatus = "failed";
|
|
367
368
|
let createdTaskId: string | undefined;
|
|
368
369
|
const notices: StuckNotice[] = [];
|
|
370
|
+
let offlineNotice: OfflineNotice | null = null;
|
|
369
371
|
const timedOut: Array<{ taskId: string; fromStatus: string }> = [];
|
|
370
372
|
|
|
371
373
|
db.transaction(() => {
|
|
@@ -377,6 +379,35 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
|
|
|
377
379
|
[runId, row.schedule_id, row.network_id, scheduledFor],
|
|
378
380
|
);
|
|
379
381
|
|
|
382
|
+
// Board #757 — do not create a task that will merely expire while its
|
|
383
|
+
// exact target session is offline. This branch deliberately precedes the
|
|
384
|
+
// overlap check: target_offline is the actionable reason for this round.
|
|
385
|
+
const targetNode = db.get<{ node_id: string; alias: string | null }>(
|
|
386
|
+
"SELECT node_id, alias FROM nodes WHERE node_id = ?1 AND network_id = ?2",
|
|
387
|
+
row.target_node_id, row.network_id,
|
|
388
|
+
);
|
|
389
|
+
if (targetNode?.alias && scheduledTargetOffline(targetNode.node_id, targetNode.alias, row.network_id, advanceAfter.getTime())) {
|
|
390
|
+
finalStatus = "skipped";
|
|
391
|
+
db.run(
|
|
392
|
+
`UPDATE scheduled_task_runs SET status = 'skipped', error_code = 'target_offline',
|
|
393
|
+
error_message = 'The target node is offline; no task was created', completed_at = datetime('now') WHERE run_id = ?1`,
|
|
394
|
+
[runId],
|
|
395
|
+
);
|
|
396
|
+
const claimed = db.run(
|
|
397
|
+
"UPDATE scheduled_tasks SET offline_alert_at = ?1 WHERE schedule_id = ?2 AND offline_alert_at IS NULL",
|
|
398
|
+
[advanceAfter.toISOString(), row.schedule_id],
|
|
399
|
+
).changes === 1;
|
|
400
|
+
if (claimed) offlineNotice = {
|
|
401
|
+
scheduleId: row.schedule_id, scheduleName: row.name, networkId: row.network_id,
|
|
402
|
+
createdBy: row.created_by, createdByNodeId: row.created_by_node_id,
|
|
403
|
+
targetAlias: targetNode.alias,
|
|
404
|
+
};
|
|
405
|
+
if (advanceSchedule) advance({ ...row, target_alias: targetNode.alias }, scheduledFor, advanceAfter);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
// Seeing the target online rearms exactly one warning for a future offline episode.
|
|
409
|
+
if (targetNode?.alias) db.run("UPDATE scheduled_tasks SET offline_alert_at = NULL WHERE schedule_id = ?1 AND offline_alert_at IS NOT NULL", [row.schedule_id]);
|
|
410
|
+
|
|
380
411
|
if (row.overlap_policy === "skip") {
|
|
381
412
|
const placeholders = OPEN_TASK_STATUSES.map((_, i) => `?${i + 2}`).join(", ");
|
|
382
413
|
// received:节点那边有没有把这条任务取走过(inbox 行被 ack,或任务已进 acked/running)。
|
|
@@ -430,10 +461,7 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
|
|
|
430
461
|
}
|
|
431
462
|
}
|
|
432
463
|
|
|
433
|
-
const node =
|
|
434
|
-
"SELECT node_id, alias FROM nodes WHERE node_id = ?1 AND network_id = ?2",
|
|
435
|
-
row.target_node_id, row.network_id,
|
|
436
|
-
);
|
|
464
|
+
const node = targetNode;
|
|
437
465
|
if (!node?.alias) {
|
|
438
466
|
finalStatus = "failed";
|
|
439
467
|
db.run(
|
|
@@ -541,6 +569,7 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
|
|
|
541
569
|
try {
|
|
542
570
|
for (const t of timedOut) logTaskEvent(t.taskId, t.fromStatus, "expired", "hub-scheduler", `stuck timeout; schedule=${row.schedule_id} run=${runId}`);
|
|
543
571
|
for (const n of notices) sendStuckNotice(n);
|
|
572
|
+
if (offlineNotice) sendOfflineNotice(offlineNotice);
|
|
544
573
|
// #523 — a run that failed at dispatch counts toward the consecutive-failure alert (task failures arrive via db.ts).
|
|
545
574
|
if (finalStatus === "failed") evaluateScheduleFailures(row.schedule_id, advanceAfter);
|
|
546
575
|
if (createdTaskId) logTaskEvent(createdTaskId, null, "delivered", "hub-scheduler", `schedule=${row.schedule_id} run=${runId}`);
|
package/src/server.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { markGzipReusable, maybeGzipResponse, trimLightTask } from "./http-gzip"
|
|
|
4
4
|
import { ifNoneMatchHits, memoStatusBody } from "./status-read-cache";
|
|
5
5
|
import { dispatchQueueInfo, queueDepthByNode, queueDepthKey } from "./task-queue-ahead.js";
|
|
6
6
|
import { sessionTaskOnDispatch } from "./session-task-on-dispatch.js";
|
|
7
|
+
import { AGENT_TEAM_SCOPE_DENIED, clearNodeFromAgentTeams, createAgentTeam, deleteAgentTeam, listAgentTeams, ownedScope, setNodeAgentTeam, updateAgentTeam } from "./agent-teams.js";
|
|
7
8
|
import { TASK_CONTENT_MAX, sessionTaskPreview } from "./shared/task-content-limit.js";
|
|
8
9
|
import { statusAliasResolverRead } from "./status-alias-resolver.js";
|
|
9
10
|
import { readNodeHealth } from "./node-health-store.js";
|
|
@@ -28,12 +29,13 @@ import { assertNodeActive } from "./lifecycle-guard.js";
|
|
|
28
29
|
import { pendingInboxCount } from "./inbox-count.js";
|
|
29
30
|
import { notifyExpiredTasks, type ExpiredTaskRow } from "./task-expiry-notice.js";
|
|
30
31
|
import { expireStaleOpenTasks } from "./task-stale-open.js";
|
|
32
|
+
import { flagOrphanTasks } from "./task-orphan.js";
|
|
31
33
|
import { addAgentNetworkScope, addHumanNetworkScope, addNetworkScope, addOwnTrafficScope, addAgentTimelineScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, resolveRestWriteNetworkId, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
32
34
|
import { lifecycleProjections, lifecycleRequestResponse } from "./node-lifecycle-read.js";
|
|
33
35
|
import { restrictedMemberSeesFile, restrictedMemberAttachmentsDenied } from "./restricted-files.js";
|
|
34
36
|
import { dmParticipantSeesFile, listDmThread, listDmThreads, sendHumanDm } from "./human-dm.js";
|
|
35
37
|
import { groupMemberSeesFile, groupUnreadFor, listGroupMessages, listGroupThreads, markGroupRead, memberGroup, sendGroupMessage } from "./group-messages.js";
|
|
36
|
-
import { getTaskAccessMode, isTaskScoped, listProjectGrants, replaceTaskGrants,
|
|
38
|
+
import { getTaskAccessMode, isTaskScoped, listProjectGrants, replaceTaskGrants, newMemberTaskAccess } from "./task-access.js";
|
|
37
39
|
import { canMessageAgent, isAgentRestricted, visibleAgents, listAgentGrants, listGroupGrants, getAgentAccessMode, replaceAgentGrants, restrictedNetworkIds, usernameIsAgentAlias, listAgentGroups, createAgentGroup, updateAgentGroup, replaceAgentGroupMembers, deleteAgentGroup, usersGrantedGroup } from "./agent-access.js";
|
|
38
40
|
import { broadcastVerdict, dispatchVerdict, humanOnlyVerdict, NODE_PERMISSION_MODES, nodeDecide, nodeIdentity, nodePermissionDeniedBody, nodePermissionsFlag, streamVerdict, writeVerdict, type NodeIdentity, type NodePermissionMode, type Verdict } from "./node-permissions.js";
|
|
39
41
|
import { validateAvatarUrl } from "./avatar-validate.js";
|
|
@@ -976,6 +978,18 @@ export function patrolExpiredTasks(): void {
|
|
|
976
978
|
}
|
|
977
979
|
}
|
|
978
980
|
|
|
981
|
+
// #758 — acked/running tasks whose node went idle/offline after starting them
|
|
982
|
+
// and never replied: one task.orphan_suspected event + one sender notice per
|
|
983
|
+
// task (task-orphan.ts). Status untouched. Same 5-minute timer as the TTL patrol.
|
|
984
|
+
export function patrolOrphanTasks(): void {
|
|
985
|
+
try {
|
|
986
|
+
const flagged = flagOrphanTasks();
|
|
987
|
+
if (flagged.length > 0) console.warn(`[patrol] flagged ${flagged.length} orphan-suspected task(s), notified ${flagged.filter((f) => f.notified).length}`);
|
|
988
|
+
} catch (e: any) {
|
|
989
|
+
console.error(`[patrol] orphan phase failed: ${e?.message || e}`);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
979
993
|
function patrolDeliveredStaleTasks(): void {
|
|
980
994
|
try {
|
|
981
995
|
const result = recordDeliveredStaleEvents();
|
|
@@ -1860,6 +1874,48 @@ return Bun.serve({
|
|
|
1860
1874
|
return withCors(req, Response.json({ ok: true, network_id: netId, user_id: targetUid, agent_access: result.agent_access, restricted: isAgentRestricted(targetUid, netId), grants: result.grants, group_grants: result.group_grants }));
|
|
1861
1875
|
}
|
|
1862
1876
|
|
|
1877
|
+
// ── Agent 团队(#764):Agent 自己的组织树,和人的部门树无关。读:网络任何成员 / 本网络的节点令牌 / Hub 管理员;
|
|
1878
|
+
// 写:owner / admin / Hub 管理员全管;团队 owner_user_id 管自己的子树(规则在 agent-teams.ts);节点令牌只读。 ──
|
|
1879
|
+
const teamMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/agent-teams(?:\/([^/]+))?$/);
|
|
1880
|
+
const nodeTeamMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/nodes\/([^/]+)\/agent-team$/);
|
|
1881
|
+
if (teamMatch || nodeTeamMatch) {
|
|
1882
|
+
const token = requestToken(req, { allowQueryToken: false });
|
|
1883
|
+
const resolved = token ? resolveToken(token) : null;
|
|
1884
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1885
|
+
const netId = decodeURIComponent((teamMatch ?? nodeTeamMatch)![1]);
|
|
1886
|
+
const notMember = () => withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
|
|
1887
|
+
if (resolved.networkId && resolved.networkId !== netId) return notMember();
|
|
1888
|
+
const hubAdmin = isHubAdminCredential(resolved);
|
|
1889
|
+
const role = resolved.networkId ? null : getUserNetworkRole(resolved.user.user_id, netId);
|
|
1890
|
+
if (!hubAdmin && !role && !resolved.networkId) return notMember();
|
|
1891
|
+
if (!db.get("SELECT 1 AS x FROM networks WHERE network_id = ?1", netId)) return withCors(req, Response.json({ ok: false, error: "network_not_found" }, { status: 404 }));
|
|
1892
|
+
const canManage = hubAdmin || (!resolved.networkId && (role === "owner" || role === "admin"));
|
|
1893
|
+
// 只看授权 Agent 的成员:看不见的节点不出现在 members / lead 里(和 Agent 列表同一判据);写路径用同一个判据,看不见 = 404。
|
|
1894
|
+
const seen = !hubAdmin && !resolved.networkId && isAgentRestricted(resolved.user.user_id, netId) ? visibleAgents(resolved.user.user_id, netId) : null;
|
|
1895
|
+
const hidden = (n: { node_id: string; alias: string | null }) => !!seen && !seen.nodeIds.includes(n.node_id) && !(n.alias && seen.aliases.includes(n.alias));
|
|
1896
|
+
if (teamMatch && !teamMatch[2] && req.method === "GET") {
|
|
1897
|
+
return withCors(req, Response.json({ ok: true, network_id: netId, teams: listAgentTeams(netId, hidden) }));
|
|
1898
|
+
}
|
|
1899
|
+
const write = teamMatch ? (teamMatch[2] ? ["PATCH", "DELETE"] : ["POST"]) : ["PUT"];
|
|
1900
|
+
if (!write.includes(req.method)) return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
|
|
1901
|
+
// null = 全管;节点令牌 / 普通成员 → 空集(任何写都 403);团队 owner → 自己的子树。
|
|
1902
|
+
const scope = canManage ? null : resolved.networkId ? new Set<string>() : ownedScope(netId, resolved.user.user_id);
|
|
1903
|
+
if (scope && scope.size === 0) return withCors(req, Response.json({ ok: false, error: AGENT_TEAM_SCOPE_DENIED }, { status: 403 }));
|
|
1904
|
+
let body: Record<string, unknown> = {};
|
|
1905
|
+
if (req.method !== "DELETE") {
|
|
1906
|
+
try { const b = await req.json(); body = b && typeof b === "object" && !Array.isArray(b) ? b as Record<string, unknown> : {}; } catch { return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 })); }
|
|
1907
|
+
}
|
|
1908
|
+
const teamId = teamMatch?.[2] ? decodeURIComponent(teamMatch[2]) : "";
|
|
1909
|
+
const nodeId = nodeTeamMatch ? decodeURIComponent(nodeTeamMatch[2]) : undefined;
|
|
1910
|
+
const r = nodeId !== undefined ? setNodeAgentTeam(netId, nodeId, scope, hidden, body.team_id)
|
|
1911
|
+
: req.method === "POST" ? createAgentTeam(netId, resolved.user.user_id, scope, hidden, body)
|
|
1912
|
+
: req.method === "PATCH" ? updateAgentTeam(netId, teamId, scope, hidden, body)
|
|
1913
|
+
: deleteAgentTeam(netId, teamId, scope);
|
|
1914
|
+
if (!r.ok) { const { ok: _ok, status, ...rest } = r; return withCors(req, Response.json({ ok: false, ...rest }, { status })); }
|
|
1915
|
+
logAudit(resolved.user.user_id, resolved.user.username, `agent_team_${req.method.toLowerCase()}`, "network", netId, JSON.stringify({ team_id: teamId || undefined, node_id: nodeId, ...body }).slice(0, 2000), undefined, netId);
|
|
1916
|
+
return withCors(req, Response.json(r, { status: req.method === "POST" ? 201 : 200 }));
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1863
1919
|
// ── 多用户:网络里的人类成员通讯录 —— 任何成员(含受限成员)都能看,只有身份字段 ──
|
|
1864
1920
|
const humansMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/humans$/);
|
|
1865
1921
|
if (humansMatch && req.method === "GET") {
|
|
@@ -2178,12 +2234,12 @@ return Bun.serve({
|
|
|
2178
2234
|
if (body.agent_access !== undefined && body.agent_access !== "all" && body.agent_access !== "granted") {
|
|
2179
2235
|
return withCors(req, Response.json({ ok: false, error: "invalid_agent_access" }, { status: 400 }));
|
|
2180
2236
|
}
|
|
2181
|
-
// task_access 缺省
|
|
2237
|
+
// task_access 缺省 newMemberTaskAccess(role)(RFC-038 §9.4,#746 起 member/viewer 为 'scoped');显式传 'all' / 'scoped' 覆盖。
|
|
2182
2238
|
if (body.task_access !== undefined && body.task_access !== "all" && body.task_access !== "scoped") {
|
|
2183
2239
|
return withCors(req, Response.json({ ok: false, error: "invalid_task_access" }, { status: 400 }));
|
|
2184
2240
|
}
|
|
2185
2241
|
const result = addNetworkMember(netId, body.user_id, body.role || "member", resolved.user.user_id, { agentAccess: body.agent_access, taskAccess: body.task_access });
|
|
2186
|
-
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "member_added", "network", netId, `${body.user_id} as ${body.role || "member"} agent_access=${body.agent_access === "all" ? "all" : "granted"} task_access=${body.task_access ??
|
|
2242
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "member_added", "network", netId, `${body.user_id} as ${body.role || "member"} agent_access=${body.agent_access === "all" ? "all" : "granted"} task_access=${body.task_access ?? newMemberTaskAccess(body.role || "member")}`, undefined, netId);
|
|
2187
2243
|
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
2188
2244
|
}
|
|
2189
2245
|
if (req.method === "PUT" && targetUid) {
|
|
@@ -4183,6 +4239,7 @@ return Bun.serve({
|
|
|
4183
4239
|
|
|
4184
4240
|
db.transaction(() => {
|
|
4185
4241
|
db.run("DELETE FROM nodes WHERE node_id = ?1", [node.node_id]);
|
|
4242
|
+
clearNodeFromAgentTeams(node.node_id); // #764
|
|
4186
4243
|
if (node.alias) {
|
|
4187
4244
|
db.run(
|
|
4188
4245
|
"DELETE FROM sessions WHERE alias = ?1 AND (network_id = ?2 OR (CAST(?2 AS TEXT) IS NULL AND network_id IS NULL))",
|
|
@@ -5086,7 +5143,7 @@ export function startHub(opts?: { port?: number; hostname?: string }): ReturnTyp
|
|
|
5086
5143
|
const deliveredStalePatrolMs = Number(process.env.COMMHUB_DELIVERED_STALE_PATROL_MS) > 0
|
|
5087
5144
|
? Number(process.env.COMMHUB_DELIVERED_STALE_PATROL_MS) : 5 * 1000;
|
|
5088
5145
|
const rateLimitSweepTimer = setInterval(sweepStaleRateLimits, rateLimitSweepMs);
|
|
5089
|
-
const taskPatrolTimer = setInterval(patrolExpiredTasks, taskPatrolMs);
|
|
5146
|
+
const taskPatrolTimer = setInterval(() => { patrolExpiredTasks(); patrolOrphanTasks(); }, taskPatrolMs);
|
|
5090
5147
|
patrolDeliveredStaleTasks();
|
|
5091
5148
|
const deliveredStalePatrolTimer = setInterval(patrolDeliveredStaleTasks, deliveredStalePatrolMs);
|
|
5092
5149
|
const scheduledTaskTimer = startScheduledTaskScheduler();
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// 场景:Hub 管理员 admin 的网络 NET。成员:
|
|
4
4
|
// alice —— 新建成员,经授权接口显式设成 scoped,授权 P1(只看)、P2(可改);
|
|
5
|
-
// (
|
|
5
|
+
// (#746 起新成员默认值 NEW_MEMBER_TASK_ACCESS 就是 'scoped',见「默认值与授权接口」)
|
|
6
6
|
// carol —— 升级前的老成员(task_access='all'):行为必须与今天逐字相同;
|
|
7
7
|
// vic —— 经授权接口显式设成 scoped 的 viewer,授权 P3(只看)。
|
|
8
8
|
// admin 建的卡:c_own(负责人 alice)、c_part(参与人 alice)、c_p1 / c_p2 / c_p3(在项目里)、c_other(与 alice 无关)。
|
|
@@ -117,10 +117,14 @@ afterAll(() => {
|
|
|
117
117
|
});
|
|
118
118
|
|
|
119
119
|
describe("默认值与授权接口", () => {
|
|
120
|
-
test("新成员默认
|
|
121
|
-
expect(NEW_MEMBER_TASK_ACCESS).toBe("
|
|
122
|
-
const all = ["ta-other", "ta-own", "ta-p1", "ta-p2", "ta-p3", "ta-part"];
|
|
120
|
+
test("#746 新成员默认 scoped:管理员建号 / POST members / 邀请码加入都落在 scoped —— 只看自己的卡与授权项目;admin 角色照旧 all", async () => {
|
|
121
|
+
expect(NEW_MEMBER_TASK_ACCESS).toBe("scoped");
|
|
123
122
|
const stamp = Date.now();
|
|
123
|
+
const names = async (t: string) => {
|
|
124
|
+
const r = await get(t, `/api/requirements?network_id=${NET}`);
|
|
125
|
+
expect(r.status).toBe(200);
|
|
126
|
+
return (r.body.requirements as any[]).map(x => x.name).filter((n: string) => n.startsWith("ta-") || n.startsWith("dm-")).sort();
|
|
127
|
+
};
|
|
124
128
|
// 管理员建号
|
|
125
129
|
const u1 = { username: `ta_new1_${stamp}`, password: PW, network_id: NET, role: "member" };
|
|
126
130
|
expect((await send(admin.token, "POST", "/api/admin/users", u1)).status).toBe(200);
|
|
@@ -128,24 +132,43 @@ describe("默认值与授权接口", () => {
|
|
|
128
132
|
// POST /members 不带 task_access
|
|
129
133
|
const u2 = register(`ta_new2_${stamp}`, PW);
|
|
130
134
|
expect((await send(admin.token, "POST", `/api/networks/${NET}/members`, { user_id: u2.user!.user_id, role: "member" })).status).toBe(200);
|
|
131
|
-
// 邀请码加入
|
|
135
|
+
// 邀请码加入(member 与 viewer 两种邀请)
|
|
132
136
|
const inv = await send(admin.token, "POST", `/api/networks/${NET}/invite`, { role: "member" });
|
|
133
137
|
const u3 = register(`ta_new3_${stamp}`, PW);
|
|
134
138
|
expect((await send(u3.token!, "POST", "/api/networks/join", { invite_code: inv.body.invite_code })).status).toBe(200);
|
|
135
|
-
|
|
139
|
+
const paths: Array<[string, string, string]> = [["admin-users", l1.body.user.user_id, l1.body.token], ["post-members", u2.user!.user_id, u2.token!], ["invite", u3.user!.user_id, u3.token!]];
|
|
140
|
+
for (const [path, id, token] of paths) {
|
|
136
141
|
const g = await get(admin.token, `/api/networks/${NET}/members/${id}/task-grants`);
|
|
137
|
-
expect(g.body.task_access).
|
|
138
|
-
expect(g.body.restricted).toBe(false);
|
|
139
|
-
expect(await listIds(token)).toEqual(all);
|
|
142
|
+
expect([path, g.body.task_access, g.body.restricted]).toEqual([path, "scoped", true]);
|
|
140
143
|
const me = await get(token, "/api/auth/me");
|
|
141
|
-
expect(me.body.networks.find((n: any) => n.network_id === NET).task_access).toBe("
|
|
144
|
+
expect(me.body.networks.find((n: any) => n.network_id === NET).task_access).toBe("scoped");
|
|
145
|
+
// 自己负责的卡 + 授权项目 P1 看得见;别人的无关卡(ta-other)列表里没有、直取也是 404
|
|
146
|
+
expect((await send(admin.token, "POST", "/api/requirements", { network_id: NET, name: `dm-own-${path}`, owner: userRef(id) })).status).toBe(201);
|
|
147
|
+
expect((await send(admin.token, "PUT", `/api/networks/${NET}/members/${id}/task-grants`, { project_grants: [P["ta-P1"]] })).status).toBe(200);
|
|
148
|
+
expect([path, await names(token)]).toEqual([path, [`dm-own-${path}`, "ta-p1"]]);
|
|
149
|
+
expect((await get(token, `/api/requirements/${C["ta-other"].id}?network_id=${NET}`)).status).toBe(404);
|
|
150
|
+
}
|
|
151
|
+
// 管理员仍能经现有接口把成员放宽到 all,之后看见全部卡
|
|
152
|
+
expect((await send(admin.token, "PUT", `/api/networks/${NET}/members/${u2.user!.user_id}/task-grants`, { task_access: "all" })).status).toBe(200);
|
|
153
|
+
expect((await names(u2.token!))).toContain("ta-other");
|
|
154
|
+
// admin 角色(POST members / 邀请码)照旧存 'all';不受这一列约束
|
|
155
|
+
const a1 = register(`ta_new5_${stamp}`, PW);
|
|
156
|
+
expect((await send(admin.token, "POST", `/api/networks/${NET}/members`, { user_id: a1.user!.user_id, role: "admin" })).status).toBe(200);
|
|
157
|
+
const invA = await send(admin.token, "POST", `/api/networks/${NET}/invite`, { role: "admin" });
|
|
158
|
+
const a2 = register(`ta_new6_${stamp}`, PW);
|
|
159
|
+
expect((await send(a2.token!, "POST", "/api/networks/join", { invite_code: invA.body.invite_code })).status).toBe(200);
|
|
160
|
+
for (const a of [a1, a2]) {
|
|
161
|
+
const g = await get(admin.token, `/api/networks/${NET}/members/${a.user!.user_id}/task-grants`);
|
|
162
|
+
expect([g.body.task_access, g.body.restricted]).toEqual(["all", false]);
|
|
163
|
+
expect(await names(a.token!)).toContain("ta-other");
|
|
142
164
|
}
|
|
143
|
-
//
|
|
165
|
+
// 升级前的老成员(carol,直接写 'all' 的行)照旧全看见
|
|
166
|
+
expect(await names(carol.token)).toContain("ta-other");
|
|
167
|
+
// POST /members 显式传 all 仍然生效
|
|
144
168
|
const u4 = register(`ta_new4_${stamp}`, PW);
|
|
145
|
-
expect((await send(admin.token, "POST", `/api/networks/${NET}/members`, { user_id: u4.user!.user_id, role: "member", task_access: "
|
|
146
|
-
expect((await get(admin.token, `/api/networks/${NET}/members/${u4.user!.user_id}/task-grants`)).body.task_access).toBe("
|
|
147
|
-
|
|
148
|
-
for (const id of [l1.body.user.user_id, u2.user!.user_id, u3.user!.user_id, u4.user!.user_id]) {
|
|
169
|
+
expect((await send(admin.token, "POST", `/api/networks/${NET}/members`, { user_id: u4.user!.user_id, role: "member", task_access: "all" })).status).toBe(200);
|
|
170
|
+
expect((await get(admin.token, `/api/networks/${NET}/members/${u4.user!.user_id}/task-grants`)).body.task_access).toBe("all");
|
|
171
|
+
for (const id of [l1.body.user.user_id, u2.user!.user_id, u3.user!.user_id, u4.user!.user_id, a1.user!.user_id, a2.user!.user_id]) {
|
|
149
172
|
expect((await send(admin.token, "DELETE", `/api/networks/${NET}/members/${id}`)).status).toBe(200);
|
|
150
173
|
}
|
|
151
174
|
});
|
package/src/task-access.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { deleteDepartmentGrantsForProject, departmentGrantsForMember, headCardsF
|
|
|
17
17
|
|
|
18
18
|
// ── 表结构(放在这里而不是 db.ts:db.ts 每多一行,文档里钉着的行号就漂一次) ──
|
|
19
19
|
// ALTER 的 DEFAULT 'all' 让**升级前已有的**成员行全部落在旧语义 —— 升级不收窄任何人;新成员按下面的
|
|
20
|
-
//
|
|
20
|
+
// newMemberTaskAccess() 入库(#746:默认 'scoped')。项目授权:行存在 = 可看,can_edit=1 = 可改。
|
|
21
21
|
try { db.exec("ALTER TABLE network_members ADD COLUMN task_access TEXT NOT NULL DEFAULT 'all'"); } catch {}
|
|
22
22
|
db.exec(`
|
|
23
23
|
CREATE TABLE IF NOT EXISTS network_member_project_grants (
|
|
@@ -33,12 +33,19 @@ db.exec(`
|
|
|
33
33
|
`);
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
36
|
+
* 新成员默认的任务范围(#746,owner 选方案 2):只看相关任务。入库路径全部显式写这一列,不靠列 DEFAULT:
|
|
37
|
+
* ① addNetworkMember —— POST /api/networks/:id/members、POST /api/admin/users(带 network_id)
|
|
38
|
+
* ② joinByInvite —— POST /api/networks/join(邀请码)
|
|
39
|
+
* owner 行(register 自动建网、createNetwork、db.ts 的 V3.13 迁移)不经过这里,照旧落列默认 'all'。
|
|
40
|
+
* 列 DEFAULT 仍是 'all'、不迁移老行 —— 升级前的成员一个都不收窄。显式 task_access 覆盖默认;
|
|
41
|
+
* 管理员随时可 PUT /api/networks/:id/members/:uid/task-grants {task_access:'all'} 放宽。
|
|
40
42
|
*/
|
|
41
|
-
export const NEW_MEMBER_TASK_ACCESS: TaskAccessMode = "
|
|
43
|
+
export const NEW_MEMBER_TASK_ACCESS: TaskAccessMode = "scoped";
|
|
44
|
+
|
|
45
|
+
/** 新成员行要写入的 task_access。admin 角色本来就不受这一列约束,照旧存 'all'(降级成 member 时与今天行为一致)。 */
|
|
46
|
+
export function newMemberTaskAccess(role: string): TaskAccessMode {
|
|
47
|
+
return UNRESTRICTED_ROLES.has(role) ? "all" : NEW_MEMBER_TASK_ACCESS;
|
|
48
|
+
}
|
|
42
49
|
|
|
43
50
|
export type TaskAccessMode = "all" | "scoped";
|
|
44
51
|
export type ProjectGrant = { project_id: string; can_edit: boolean };
|
|
@@ -201,31 +201,33 @@ function noteEvent(taskId: string, status: string, detail: string): void {
|
|
|
201
201
|
} catch {}
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
-
function deliverToAgent(alias: string, networkId: string | null, inReplyTo: string, text: string, meta: Record<string, unknown
|
|
205
|
-
const id =
|
|
204
|
+
function deliverToAgent(alias: string, networkId: string | null, inReplyTo: string, text: string, meta: Record<string, unknown>, status = "expired", prefix = "exp_"): string {
|
|
205
|
+
const id = `${prefix}${uuidv4().replace(/-/g, "").slice(0, 20)}`;
|
|
206
206
|
const nodeId = agentSession(alias, networkId)?.node_id ?? null;
|
|
207
207
|
db.run(
|
|
208
208
|
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, in_reply_to, requires_response, network_id, meta_json)
|
|
209
209
|
VALUES (?1, ?2, ?3, 'reply', 'normal', ?4, 'hub', ?5, 'none', ?6, ?7)`,
|
|
210
210
|
[id, alias, nodeId, text, inReplyTo, networkId, JSON.stringify(meta)],
|
|
211
211
|
);
|
|
212
|
-
pushEvent(alias, { type: "new_reply", inbox_count: pendingInboxCount(alias, networkId), from: "hub", message_id: id, in_reply_to: inReplyTo, status
|
|
212
|
+
pushEvent(alias, { type: "new_reply", inbox_count: pendingInboxCount(alias, networkId), from: "hub", message_id: id, in_reply_to: inReplyTo, status }, networkId);
|
|
213
213
|
return id;
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
-
|
|
216
|
+
/** 按 route 投递一条通知。#758 孤儿任务通知复用它(kind / status / id 前缀不同)。 */
|
|
217
|
+
export function deliver(
|
|
217
218
|
route: Extract<ExpirySenderRoute, { kind: "agent" | "user" }>,
|
|
218
219
|
networkId: string | null,
|
|
219
220
|
fromAlias: string,
|
|
220
221
|
inReplyTo: string,
|
|
221
222
|
notice: { title: string; text: string },
|
|
222
223
|
meta: Record<string, unknown>,
|
|
224
|
+
as: { kind: string; status: string; agentPrefix: string; userPrefix: string } = { kind: TASK_EXPIRED_NOTICE_KIND, status: "expired", agentPrefix: "exp_", userPrefix: "dm_expired_" },
|
|
223
225
|
): string | null {
|
|
224
|
-
if (route.kind === "agent") return deliverToAgent(route.alias, networkId, inReplyTo, notice.text, meta);
|
|
226
|
+
if (route.kind === "agent") return deliverToAgent(route.alias, networkId, inReplyTo, notice.text, meta, as.status, as.agentPrefix);
|
|
225
227
|
if (!networkId) return null;
|
|
226
228
|
return sendAgentNotice({
|
|
227
|
-
networkId, userId: route.userId, fromAlias, kind:
|
|
228
|
-
title: notice.title, text: notice.text, severity: "warning", meta, idPrefix:
|
|
229
|
+
networkId, userId: route.userId, fromAlias, kind: as.kind,
|
|
230
|
+
title: notice.title, text: notice.text, severity: "warning", meta, idPrefix: as.userPrefix,
|
|
229
231
|
});
|
|
230
232
|
}
|
|
231
233
|
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// #758 —— 孤儿任务:acked / running 超过阈值,接收节点在开工之后回到 idle / offline 却没回复。
|
|
2
|
+
// HTTP 集成测试(真实 Bun.serve,私有端口,临时库);test2123 在真实 PostgreSQL 上原样再跑一遍。
|
|
3
|
+
//
|
|
4
|
+
// 任务经真入口 POST /api/task 派出,目标用自己的节点令牌经 /mcp ack_inbox / report_status 推进;
|
|
5
|
+
// 只把任务的时间戳往前拨,然后调巡检(server.ts patrolOrphanTasks,5 分钟定时器调的就是它)。
|
|
6
|
+
//
|
|
7
|
+
// 钉住:
|
|
8
|
+
// 1. 孤儿(agent 派的)→ 记一条 task.orphan_suspected、发一条 inbox reply 给派活节点,任务状态不变;第二次巡检不重发。
|
|
9
|
+
// 2. 人派的孤儿 → user_inbox 一条。
|
|
10
|
+
// 3. 节点 working → 不标。
|
|
11
|
+
// 4. 不到阈值 → 不标;阈值可由 COMMHUB_ORPHAN_TASK_MINUTES 改,0 = 关。
|
|
12
|
+
// 5. 节点开工之后没再报过状态 → 不标。
|
|
13
|
+
// 6. scheduler 派的孤儿 → 标,但不通知。
|
|
14
|
+
// 7. TTL 巡检(patrolExpiredTasks)不碰孤儿阶段:不写 orphan 事件。
|
|
15
|
+
//
|
|
16
|
+
// 跑法:cd server && bun test src/task-orphan-http.test.ts
|
|
17
|
+
|
|
18
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
19
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
|
|
23
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-orphan-"));
|
|
24
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
25
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
26
|
+
process.env.HOST = "127.0.0.1";
|
|
27
|
+
const ENV = "COMMHUB_ORPHAN_TASK_MINUTES";
|
|
28
|
+
const LOOKBACK_ENV = "COMMHUB_ORPHAN_TASK_LOOKBACK_HOURS";
|
|
29
|
+
delete process.env[ENV];
|
|
30
|
+
delete process.env[LOOKBACK_ENV];
|
|
31
|
+
const PW = "Orphan123!xyz";
|
|
32
|
+
const stamp = Date.now();
|
|
33
|
+
|
|
34
|
+
let db: any;
|
|
35
|
+
let patrol: () => void;
|
|
36
|
+
let ttlPatrol: () => void;
|
|
37
|
+
let flag: (o?: { minutes?: number | null }) => Array<{ task_id: string; notified: string | null }>;
|
|
38
|
+
let hub: any = null;
|
|
39
|
+
let BASE = "";
|
|
40
|
+
let NET = "";
|
|
41
|
+
let umaToken = "", umaId = "";
|
|
42
|
+
let EVENT = "";
|
|
43
|
+
type Node = { alias: string; nodeId: string; token: string };
|
|
44
|
+
const nodes: Record<"sender" | "target", Node> = {} as any;
|
|
45
|
+
|
|
46
|
+
async function send(token: string, method: string, path: string, payload?: unknown) {
|
|
47
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
48
|
+
method,
|
|
49
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
50
|
+
body: payload === undefined ? undefined : JSON.stringify(payload),
|
|
51
|
+
});
|
|
52
|
+
return { status: res.status, body: await res.json().catch(() => null) as any };
|
|
53
|
+
}
|
|
54
|
+
async function tool(token: string, name: string, args: Record<string, unknown>) {
|
|
55
|
+
const res = await fetch(`${BASE}/mcp`, {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
|
|
58
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
|
|
59
|
+
});
|
|
60
|
+
const raw = await res.text();
|
|
61
|
+
const data = raw.split("\n").filter((x) => x.startsWith("data:"));
|
|
62
|
+
const payload = data.length ? JSON.parse(data.at(-1)!.slice(5).trim()) : JSON.parse(raw);
|
|
63
|
+
return JSON.parse(payload.result.content[0].text);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let seq = 0;
|
|
67
|
+
async function dispatch(token: string): Promise<{ id: string; content: string }> {
|
|
68
|
+
const content = `orphan case ${stamp} #${++seq}`;
|
|
69
|
+
const r = await send(token, "POST", "/api/task", { alias: nodes.target.alias, task: content, network_id: NET });
|
|
70
|
+
expect(r.status).toBe(200);
|
|
71
|
+
expect(r.body.ok).toBe(true);
|
|
72
|
+
return { id: r.body.task_id, content };
|
|
73
|
+
}
|
|
74
|
+
async function ack(taskId: string) {
|
|
75
|
+
const r = await tool(nodes.target.token, "ack_inbox", { alias: nodes.target.alias, message_id: taskId, network_id: NET });
|
|
76
|
+
expect(r.ok).toBe(true);
|
|
77
|
+
expect(taskRow(taskId).status).toBe("acked");
|
|
78
|
+
}
|
|
79
|
+
async function targetStatus(status: string, task?: string) {
|
|
80
|
+
const r = await tool(nodes.target.token, "report_status", { resume_id: `sdk-${nodes.target.nodeId}`, alias: nodes.target.alias, status, task, node_id: nodes.target.nodeId, network_id: NET });
|
|
81
|
+
expect(r.ok).toBe(true);
|
|
82
|
+
}
|
|
83
|
+
/** 任务的时间戳拨到 `minutes` 分钟前(节点的 last_seen_at 不动 = 「之后还报过状态」)。 */
|
|
84
|
+
function age(taskId: string, minutes: number) {
|
|
85
|
+
const off = `-${minutes * 60} seconds`;
|
|
86
|
+
db.run(
|
|
87
|
+
`UPDATE tasks SET created_at = datetime('now', ?2),
|
|
88
|
+
delivered_at = CASE WHEN delivered_at IS NULL THEN NULL ELSE datetime('now', ?2) END,
|
|
89
|
+
started_at = CASE WHEN started_at IS NULL THEN NULL ELSE datetime('now', ?2) END,
|
|
90
|
+
consumed_at = CASE WHEN consumed_at IS NULL THEN NULL ELSE datetime('now', ?2) END
|
|
91
|
+
WHERE task_id = ?1`,
|
|
92
|
+
[taskId, off],
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
const taskRow = (taskId: string) => db.get("SELECT status, result, completed_at FROM tasks WHERE task_id = ?1", taskId) as { status: string; result: string | null; completed_at: string | null };
|
|
96
|
+
const orphanEvents = (taskId: string) => db.all("SELECT from_status, to_status, event_type, event_key, actor, detail FROM task_events WHERE task_id = ?1 AND event_type = ?2", taskId, EVENT) as any[];
|
|
97
|
+
const agentNotices = () => db.all("SELECT in_reply_to, type, requires_response, content FROM inbox WHERE network_id = ?1 AND session_name = ?2 AND from_session = 'hub'", NET, nodes.sender.alias) as any[];
|
|
98
|
+
const userNotices = () => db.all("SELECT kind, from_session, content FROM user_inbox WHERE network_id = ?1 AND user_id = ?2", NET, umaId) as any[];
|
|
99
|
+
|
|
100
|
+
function withMinutes<T>(value: string | undefined, fn: () => T): T {
|
|
101
|
+
const prev = process.env[ENV];
|
|
102
|
+
if (value === undefined) delete process.env[ENV]; else process.env[ENV] = value;
|
|
103
|
+
try { return fn(); } finally { if (prev === undefined) delete process.env[ENV]; else process.env[ENV] = prev; }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
beforeAll(async () => {
|
|
107
|
+
({ db } = await import("./db.js"));
|
|
108
|
+
const { addNetworkMember, createNetworkTokenForNode, register } = await import("./auth.js");
|
|
109
|
+
const orphan: any = await import("./task-orphan.js");
|
|
110
|
+
EVENT = orphan.ORPHAN_EVENT_TYPE;
|
|
111
|
+
flag = orphan.flagOrphanTasks;
|
|
112
|
+
const boss = register(`orphan_boss_${stamp}`, PW);
|
|
113
|
+
expect(boss.ok).toBe(true);
|
|
114
|
+
NET = boss.network_id!;
|
|
115
|
+
const bossId = boss.user!.user_id;
|
|
116
|
+
const uma = register(`orphan_uma_${stamp}`, PW);
|
|
117
|
+
umaToken = uma.token!; umaId = uma.user!.user_id;
|
|
118
|
+
expect(addNetworkMember(NET, umaId, "member", bossId, { agentAccess: "all" }).ok).toBe(true);
|
|
119
|
+
for (const key of ["sender", "target"] as const) {
|
|
120
|
+
const alias = `orphan-${key}-${stamp}`;
|
|
121
|
+
const nodeId = `n_orphan_${key}_${stamp}`;
|
|
122
|
+
const minted = createNetworkTokenForNode(bossId, NET, alias, nodeId);
|
|
123
|
+
expect(minted.ok).toBe(true);
|
|
124
|
+
nodes[key] = { alias, nodeId, token: minted.token! };
|
|
125
|
+
}
|
|
126
|
+
const mod: any = await import("./server.js");
|
|
127
|
+
patrol = mod.patrolOrphanTasks;
|
|
128
|
+
ttlPatrol = mod.patrolExpiredTasks;
|
|
129
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
130
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
131
|
+
for (const n of Object.values(nodes)) {
|
|
132
|
+
const r = await tool(n.token, "report_status", { resume_id: `sdk-${n.nodeId}`, alias: n.alias, status: "idle", node_id: n.nodeId, network_id: NET });
|
|
133
|
+
expect(r.ok).toBe(true);
|
|
134
|
+
}
|
|
135
|
+
}, 30_000);
|
|
136
|
+
|
|
137
|
+
beforeEach(() => {
|
|
138
|
+
db.run("UPDATE tasks SET status = 'replied', completed_at = datetime('now') WHERE network_id = ?1 AND status IN ('created', 'delivered', 'acked', 'running')", [NET]);
|
|
139
|
+
db.run("DELETE FROM inbox WHERE network_id = ?1 AND from_session = 'hub'", [NET]);
|
|
140
|
+
db.run("DELETE FROM user_inbox WHERE network_id = ?1", [NET]);
|
|
141
|
+
// 上一条用例可能把目标留在 offline / working:派活前先让它回到 idle(否则 /api/task 回 202 排队)。
|
|
142
|
+
db.run("UPDATE sessions SET status = 'idle' WHERE alias = ?1 AND network_id = ?2", [nodes.target.alias, NET]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
afterAll(() => {
|
|
146
|
+
try { hub?.stop?.(true); } catch {}
|
|
147
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("#758 orphan detected", () => {
|
|
151
|
+
test("acked 2h, node idle since → one event + one inbox reply to the sending agent; status unchanged; second pass silent", async () => {
|
|
152
|
+
const t = await dispatch(nodes.sender.token);
|
|
153
|
+
await ack(t.id);
|
|
154
|
+
age(t.id, 120);
|
|
155
|
+
await targetStatus("idle");
|
|
156
|
+
|
|
157
|
+
withMinutes(undefined, () => patrol());
|
|
158
|
+
|
|
159
|
+
expect(taskRow(t.id)).toMatchObject({ status: "acked", result: null, completed_at: null });
|
|
160
|
+
const ev = orphanEvents(t.id);
|
|
161
|
+
expect(ev.length).toBe(1);
|
|
162
|
+
expect(ev[0]).toMatchObject({ from_status: "acked", to_status: "acked", event_key: EVENT, actor: "patrol" });
|
|
163
|
+
expect(ev[0].detail).toContain(`notify: agent ${nodes.sender.alias}`);
|
|
164
|
+
const n = agentNotices();
|
|
165
|
+
expect(n.length).toBe(1);
|
|
166
|
+
expect(n[0]).toMatchObject({ in_reply_to: t.id, type: "reply", requires_response: "none" });
|
|
167
|
+
expect(n[0].content).toContain(nodes.target.alias);
|
|
168
|
+
expect(n[0].content).toContain("没有改这条任务的状态");
|
|
169
|
+
|
|
170
|
+
// 第二、三次巡检:不重发、不重记。
|
|
171
|
+
withMinutes(undefined, () => { patrol(); patrol(); });
|
|
172
|
+
expect(orphanEvents(t.id).length).toBe(1);
|
|
173
|
+
expect(agentNotices().length).toBe(1);
|
|
174
|
+
expect(taskRow(t.id).status).toBe("acked");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("running task, node went offline afterwards → flagged; a human sender gets one user_inbox notice", async () => {
|
|
178
|
+
const t = await dispatch(umaToken);
|
|
179
|
+
await ack(t.id);
|
|
180
|
+
await targetStatus("working", t.content);
|
|
181
|
+
expect(taskRow(t.id).status).toBe("running");
|
|
182
|
+
age(t.id, 90);
|
|
183
|
+
await targetStatus("idle");
|
|
184
|
+
db.run("UPDATE sessions SET status = 'offline' WHERE alias = ?1 AND network_id = ?2", [nodes.target.alias, NET]);
|
|
185
|
+
|
|
186
|
+
const out = withMinutes(undefined, () => flag());
|
|
187
|
+
expect(out).toEqual([{ task_id: t.id, notified: "user" }]);
|
|
188
|
+
expect(taskRow(t.id).status).toBe("running");
|
|
189
|
+
const u = userNotices();
|
|
190
|
+
expect(u.length).toBe(1);
|
|
191
|
+
expect(u[0]).toMatchObject({ kind: "task_orphan_suspected", from_session: nodes.target.alias });
|
|
192
|
+
expect(withMinutes(undefined, () => flag())).toEqual([]);
|
|
193
|
+
expect(userNotices().length).toBe(1);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("#758 not orphans", () => {
|
|
198
|
+
test("node is working (busy) → not flagged", async () => {
|
|
199
|
+
const t = await dispatch(nodes.sender.token);
|
|
200
|
+
await ack(t.id);
|
|
201
|
+
age(t.id, 120);
|
|
202
|
+
await targetStatus("working", "something else");
|
|
203
|
+
withMinutes(undefined, () => patrol());
|
|
204
|
+
expect(orphanEvents(t.id).length).toBe(0);
|
|
205
|
+
expect(agentNotices().length).toBe(0);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("younger than the threshold → not flagged; env lowers it; 0 turns it off", async () => {
|
|
209
|
+
const t = await dispatch(nodes.sender.token);
|
|
210
|
+
await ack(t.id);
|
|
211
|
+
age(t.id, 45);
|
|
212
|
+
await targetStatus("idle");
|
|
213
|
+
withMinutes(undefined, () => patrol());
|
|
214
|
+
expect(orphanEvents(t.id).length).toBe(0);
|
|
215
|
+
withMinutes("0", () => patrol());
|
|
216
|
+
expect(orphanEvents(t.id).length).toBe(0);
|
|
217
|
+
withMinutes("30", () => patrol());
|
|
218
|
+
expect(orphanEvents(t.id).length).toBe(1);
|
|
219
|
+
expect(agentNotices().length).toBe(1);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("node has not reported any status since the task started → not flagged", async () => {
|
|
223
|
+
const t = await dispatch(nodes.sender.token);
|
|
224
|
+
await ack(t.id);
|
|
225
|
+
age(t.id, 120);
|
|
226
|
+
await targetStatus("idle");
|
|
227
|
+
db.run("UPDATE sessions SET last_seen_at = datetime('now', '-180 minutes'), updated_at = datetime('now', '-180 minutes') WHERE alias = ?1 AND network_id = ?2", [nodes.target.alias, NET]);
|
|
228
|
+
withMinutes(undefined, () => patrol());
|
|
229
|
+
expect(orphanEvents(t.id).length).toBe(0);
|
|
230
|
+
expect(agentNotices().length).toBe(0);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe("#758 lookback cap", () => {
|
|
235
|
+
test("a 10-day-old orphan is not flagged by default (72h cap) but is with lookback 0", async () => {
|
|
236
|
+
// 10 天前的那条直接写库(目标上已有开着的任务时 /api/task 回 202 排队,而这里要两条同时开着)。
|
|
237
|
+
const t = { id: `orphan_old_${stamp}` };
|
|
238
|
+
db.run(
|
|
239
|
+
`INSERT INTO tasks (task_id, from_name, from_node_id, to_name, to_node_id, priority, status, content, requires_response, created_at, delivered_at, network_id)
|
|
240
|
+
VALUES (?1, ?2, ?3, ?4, ?5, 'normal', 'acked', 'ten days old', 'reply', datetime('now', '-240 hours'), datetime('now', '-240 hours'), ?6)`,
|
|
241
|
+
[t.id, nodes.sender.alias, nodes.sender.nodeId, nodes.target.alias, nodes.target.nodeId, NET],
|
|
242
|
+
);
|
|
243
|
+
await targetStatus("idle");
|
|
244
|
+
withMinutes(undefined, () => patrol());
|
|
245
|
+
expect(orphanEvents(t.id).length).toBe(0);
|
|
246
|
+
expect(agentNotices().length).toBe(0);
|
|
247
|
+
// 71 小时的照常标出:上限只挡更老的。
|
|
248
|
+
const recent = await dispatch(nodes.sender.token);
|
|
249
|
+
await ack(recent.id);
|
|
250
|
+
age(recent.id, 71 * 60);
|
|
251
|
+
await targetStatus("idle");
|
|
252
|
+
withMinutes(undefined, () => patrol());
|
|
253
|
+
expect(orphanEvents(recent.id).length).toBe(1);
|
|
254
|
+
expect(orphanEvents(t.id).length).toBe(0);
|
|
255
|
+
process.env[LOOKBACK_ENV] = "0";
|
|
256
|
+
try { withMinutes(undefined, () => patrol()); } finally { delete process.env[LOOKBACK_ENV]; }
|
|
257
|
+
expect(orphanEvents(t.id).length).toBe(1);
|
|
258
|
+
expect(agentNotices().map((n) => n.in_reply_to).sort()).toEqual([recent.id, t.id].sort());
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe("#758 scheduler and regression", () => {
|
|
263
|
+
test("scheduler-sent orphan → flagged, sender not notified", async () => {
|
|
264
|
+
const id = `orphan_sched_${stamp}`;
|
|
265
|
+
db.run(
|
|
266
|
+
`INSERT INTO tasks (task_id, from_name, to_name, to_node_id, priority, status, content, requires_response, created_at, delivered_at, network_id)
|
|
267
|
+
VALUES (?1, 'scheduler', ?2, ?3, 'normal', 'acked', 'scheduled run', 'reply', datetime('now', '-120 minutes'), datetime('now', '-120 minutes'), ?4)`,
|
|
268
|
+
[id, nodes.target.alias, nodes.target.nodeId, NET],
|
|
269
|
+
);
|
|
270
|
+
await targetStatus("idle");
|
|
271
|
+
const out = withMinutes(undefined, () => flag());
|
|
272
|
+
expect(out).toEqual([{ task_id: id, notified: null }]);
|
|
273
|
+
expect(orphanEvents(id)[0].detail).toContain("notify: none (scheduler)");
|
|
274
|
+
expect(agentNotices().length).toBe(0);
|
|
275
|
+
expect(userNotices().length).toBe(0);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("the TTL patrol alone does not run the orphan phase", async () => {
|
|
279
|
+
const t = await dispatch(nodes.sender.token);
|
|
280
|
+
await ack(t.id);
|
|
281
|
+
age(t.id, 120);
|
|
282
|
+
await targetStatus("idle");
|
|
283
|
+
withMinutes(undefined, () => ttlPatrol());
|
|
284
|
+
expect(orphanEvents(t.id).length).toBe(0);
|
|
285
|
+
expect(taskRow(t.id).status).toBe("acked");
|
|
286
|
+
});
|
|
287
|
+
});
|