@sleep2agi/commhub-server 0.9.0-preview.114 → 0.9.0-preview.116
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-teams-http.test.ts +323 -0
- package/src/agent-teams.ts +222 -0
- package/src/auth.ts +8 -5
- package/src/create-node-unicode-name.test.ts +2 -0
- package/src/db.ts +3 -0
- package/src/department-heads-http.test.ts +2 -1
- package/src/node-alias-control.ts +12 -0
- package/src/node-token-privilege-http.test.ts +9 -0
- package/src/rename.ts +15 -0
- 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/schedule-agent-mcp-http.test.ts +18 -0
- package/src/schedule-agent.ts +3 -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/tools.ts +3 -1
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// C0/C1 controls plus Unicode's two dedicated line separators. The latter are
|
|
2
|
+
// not General_Category=Cc, but they still split log/source-prefix lines.
|
|
3
|
+
const CONTROL_CHARACTER = /[\p{Cc}\u2028\u2029]/u;
|
|
4
|
+
|
|
5
|
+
export function aliasControlCharacter(alias: string): string | null {
|
|
6
|
+
return [...alias].find((char) => CONTROL_CHARACTER.test(char)) ?? null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function describeAliasControlCharacter(char: string): string {
|
|
10
|
+
const codePoint = char.codePointAt(0) ?? 0;
|
|
11
|
+
return `U+${codePoint.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
12
|
+
}
|
|
@@ -129,6 +129,15 @@ describe("account and network management need a user token", () => {
|
|
|
129
129
|
expect(mine.body.ok).toBe(true);
|
|
130
130
|
expect((await call(ntok, "POST", "/api/node-rename/abort", { txn_id: mine.body.txn_id })).body.ok).toBe(true);
|
|
131
131
|
});
|
|
132
|
+
test("rename rejects line breaks, tabs and other control characters before reserving an alias", async () => {
|
|
133
|
+
for (const newAlias of ["hidden\nnode", "hidden\tnode", "hidden\u001fnode", "hidden\u007fnode", "hidden\u2028node", "hidden\u2029node"]) {
|
|
134
|
+
const r = await call(ntok, "POST", "/api/node-rename/prepare", { network_id: NET, old_alias: "ntok-node", new_alias: newAlias });
|
|
135
|
+
expect(r.status).toBe(400);
|
|
136
|
+
expect(r.body).toMatchObject({ ok: false, error: "node_alias_invalid", code: "node_alias_invalid", reason: "control_character" });
|
|
137
|
+
expect(r.body.message).toContain("control character U+");
|
|
138
|
+
}
|
|
139
|
+
expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM rename_txn WHERE network_id = ?1 AND status = 'prepared'", NET)?.n).toBe(0);
|
|
140
|
+
});
|
|
132
141
|
});
|
|
133
142
|
|
|
134
143
|
describe("/api/auth/me says what the credential is", () => {
|
package/src/rename.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { getUserNetworkRole, getNetworkMembers } from "./auth";
|
|
|
18
18
|
import { canSeeAgent, isAgentRestricted } from "./agent-access";
|
|
19
19
|
import { pushEvent } from "./push";
|
|
20
20
|
import { eventBus } from "./event_bus";
|
|
21
|
+
import { aliasControlCharacter, describeAliasControlCharacter } from "./node-alias-control";
|
|
21
22
|
|
|
22
23
|
export interface RenameResult {
|
|
23
24
|
ok: boolean;
|
|
@@ -25,6 +26,9 @@ export interface RenameResult {
|
|
|
25
26
|
error?: string;
|
|
26
27
|
code?: string;
|
|
27
28
|
suggested?: string;
|
|
29
|
+
reason?: string;
|
|
30
|
+
char?: string;
|
|
31
|
+
message?: string;
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
export interface CanonicalAlias {
|
|
@@ -111,6 +115,17 @@ export function prepareRename(
|
|
|
111
115
|
): RenameResult {
|
|
112
116
|
if (!hasWriteAccess(userId, networkId)) return { ok: false, error: "no write access to this network" };
|
|
113
117
|
if (!oldAlias || !newAlias) return { ok: false, error: "old and new alias required" };
|
|
118
|
+
const control = aliasControlCharacter(newAlias);
|
|
119
|
+
if (control !== null) {
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
code: "node_alias_invalid",
|
|
123
|
+
error: "node_alias_invalid",
|
|
124
|
+
reason: "control_character",
|
|
125
|
+
char: control,
|
|
126
|
+
message: `alias cannot contain control character ${describeAliasControlCharacter(control)}`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
114
129
|
if (oldAlias === newAlias) return { ok: false, error: "old and new alias are identical" };
|
|
115
130
|
|
|
116
131
|
// old-alias must exist as a session in this network
|
|
@@ -58,6 +58,8 @@ beforeAll(async () => {
|
|
|
58
58
|
const mk = async (username: string, role: string) => {
|
|
59
59
|
expect((await rest(U.owner.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role })).status).toBe(200);
|
|
60
60
|
const login = await rest("", "POST", "/api/auth/login", { username, password: PW });
|
|
61
|
+
// #746 起新成员默认 scoped;这里的 member / viewer 模拟老成员('all'),scoped 那位下面再收窄。
|
|
62
|
+
expect((await rest(U.owner.token, "PUT", `/api/networks/${NET}/members/${login.body.user.user_id}/task-grants`, { task_access: "all" })).status).toBe(200);
|
|
61
63
|
return { token: login.body.token as string, id: login.body.user.user_id as string };
|
|
62
64
|
};
|
|
63
65
|
const stamp = Date.now();
|
|
@@ -78,7 +78,7 @@ beforeAll(async () => {
|
|
|
78
78
|
const stamp = Date.now();
|
|
79
79
|
alice = await mk(`rs_alice_${stamp}`);
|
|
80
80
|
carol = await mk(`rs_carol_${stamp}`);
|
|
81
|
-
//
|
|
81
|
+
// #746 起新成员默认就是 scoped;这里仍显式设一次,不依赖默认值。
|
|
82
82
|
expect((await send(admin.token, "PUT", `/api/networks/${NET}/members/${alice.id}/task-grants`, { task_access: "scoped" })).status).toBe(200);
|
|
83
83
|
db.run("UPDATE network_members SET task_access = 'all' WHERE network_id = ?1 AND user_id = ?2", [NET, carol.id]);
|
|
84
84
|
|
|
@@ -59,6 +59,8 @@ beforeAll(async () => {
|
|
|
59
59
|
expect((await rest(U.owner.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role: "member" })).status).toBe(200);
|
|
60
60
|
const login = await rest("", "POST", "/api/auth/login", { username, password: PW });
|
|
61
61
|
U.member = { token: login.body.token, id: login.body.user.user_id };
|
|
62
|
+
// #746 起新成员默认 scoped;这里测的是 'all' 成员的 REST 键,显式放宽。
|
|
63
|
+
expect((await rest(U.owner.token, "PUT", `/api/networks/${NET}/members/${U.member.id}/task-grants`, { task_access: "all" })).status).toBe(200);
|
|
62
64
|
for (const [k, alias] of [["nodeA", "示例-甲"], ["nodeB", "示例-乙"], ["nodeC", "示例-丙"]] as const) {
|
|
63
65
|
const nodeId = `node_sp_${k}`;
|
|
64
66
|
U[k] = { token: createNetworkTokenForNode(U.owner.id, NET, alias, nodeId).token!, id: nodeId };
|
|
@@ -108,6 +108,24 @@ describe("happy path from a node", () => {
|
|
|
108
108
|
expect((await mcp(node.a.token, "schedule_run_now", { schedule_id: s.schedule_id })).error).toBe("schedule_cancelled");
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
+
test("base_revision prevents a stale Agent update; omitting it keeps legacy last-write behavior", async () => {
|
|
112
|
+
const made = (await create("a")).schedule;
|
|
113
|
+
const baseRevision = made.revision;
|
|
114
|
+
const first = await mcp(node.a.token, "schedule_update", {
|
|
115
|
+
schedule_id: made.schedule_id, base_revision: baseRevision, name: "first writer",
|
|
116
|
+
});
|
|
117
|
+
expect(first).toMatchObject({ ok: true, schedule: { name: "first writer", revision: baseRevision + 1 } });
|
|
118
|
+
|
|
119
|
+
const stale = await mcp(node.a.token, "schedule_update", {
|
|
120
|
+
schedule_id: made.schedule_id, base_revision: baseRevision, name: "stale writer",
|
|
121
|
+
});
|
|
122
|
+
expect(stale).toEqual({ ok: false, error: "revision_conflict", current_revision: baseRevision + 1 });
|
|
123
|
+
expect((await mcp(node.a.token, "schedule_get", { schedule_id: made.schedule_id })).schedule.name).toBe("first writer");
|
|
124
|
+
|
|
125
|
+
const legacy = await mcp(node.a.token, "schedule_update", { schedule_id: made.schedule_id, name: "legacy writer" });
|
|
126
|
+
expect(legacy).toMatchObject({ ok: true, schedule: { name: "legacy writer", revision: baseRevision + 2 } });
|
|
127
|
+
});
|
|
128
|
+
|
|
111
129
|
test("a schedule for another node the caller can send_task to; the target can read it but not change it", async () => {
|
|
112
130
|
const c = await create("a", { target_node_id: node.b.id });
|
|
113
131
|
expect(c.ok).toBe(true);
|
package/src/schedule-agent.ts
CHANGED
|
@@ -126,6 +126,9 @@ export function agentUpdateSchedule(id: NodeIdentity, canWrite: boolean, schedul
|
|
|
126
126
|
if (gate) return gate;
|
|
127
127
|
const row = ownRow(id, scheduleId);
|
|
128
128
|
if (!isRow(row)) return row;
|
|
129
|
+
if (body.base_revision !== undefined && Number(body.base_revision) !== row.revision) {
|
|
130
|
+
return fail("revision_conflict", { current_revision: row.revision });
|
|
131
|
+
}
|
|
129
132
|
const retarget = body.target_node_id !== undefined && body.target_node_id !== row.target_node_id;
|
|
130
133
|
let target = { node_id: row.target_node_id, alias: row.target_alias };
|
|
131
134
|
if (retarget) {
|
|
@@ -108,23 +108,27 @@ describe("scheduler doorbell for sessions reported without node_id", () => {
|
|
|
108
108
|
}
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
-
test("
|
|
111
|
+
test("an offline session is skipped even if a stale subscriber is still connected", async () => {
|
|
112
112
|
seedSession("offline", null);
|
|
113
113
|
const sub = subscribe();
|
|
114
114
|
try {
|
|
115
115
|
await settle();
|
|
116
116
|
const result = dispatchScheduledOccurrence(seedSchedule(), new Date().toISOString(), false);
|
|
117
117
|
await settle();
|
|
118
|
-
expect(result.status).toBe("
|
|
119
|
-
expect(
|
|
118
|
+
expect(result.status).toBe("skipped");
|
|
119
|
+
expect(db.get<{ error_code: string; task_id: string | null }>("SELECT error_code, task_id FROM scheduled_task_runs WHERE run_id = ?1", result.runId))
|
|
120
|
+
.toEqual({ error_code: "target_offline", task_id: null });
|
|
121
|
+
expect(sub.frames.filter((f) => f.type === "new_task")).toHaveLength(0);
|
|
120
122
|
} finally {
|
|
121
123
|
await sub.close();
|
|
122
124
|
}
|
|
123
125
|
});
|
|
124
126
|
|
|
125
|
-
test("offline session with nobody listening
|
|
127
|
+
test("offline session with nobody listening is skipped without a task", () => {
|
|
126
128
|
seedSession("offline", null);
|
|
127
129
|
const result = dispatchScheduledOccurrence(seedSchedule(), new Date().toISOString(), false);
|
|
128
|
-
expect(result.status).toBe("
|
|
130
|
+
expect(result.status).toBe("skipped");
|
|
131
|
+
expect(db.get<{ error_code: string; task_id: string | null }>("SELECT error_code, task_id FROM scheduled_task_runs WHERE run_id = ?1", result.runId))
|
|
132
|
+
.toEqual({ error_code: "target_offline", task_id: null });
|
|
129
133
|
});
|
|
130
134
|
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Board #757 — an offline scheduled target produces an auditable skip, not a
|
|
2
|
+
// doomed task; each offline episode warns its human/Agent creator once.
|
|
3
|
+
// Run: COMMHUB_DB=/tmp/board757.db bun test src/scheduled-offline.test.ts
|
|
4
|
+
// PG: registered in tests/test2123-hub-postgres-ladder/run.sh.
|
|
5
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
6
|
+
import { register } from "./auth.js";
|
|
7
|
+
import { db } from "./db.js";
|
|
8
|
+
import { dispatchScheduledOccurrence, getScheduleRow } from "./scheduled-tasks.js";
|
|
9
|
+
import { SCHEDULE_TARGET_OFFLINE_NOTICE_KIND, scheduledTargetOffline } from "./scheduled-offline.js";
|
|
10
|
+
|
|
11
|
+
const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
|
|
12
|
+
if (!activeDbPath) throw new Error("scheduled-offline requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
|
|
13
|
+
|
|
14
|
+
const stamp = Date.now();
|
|
15
|
+
const TARGET_ID = `n_offline_target_${stamp}`;
|
|
16
|
+
const TARGET = `offline-target-${stamp}`;
|
|
17
|
+
const CREATOR_ID = `n_offline_creator_${stamp}`;
|
|
18
|
+
const CREATOR = `offline-creator-${stamp}`;
|
|
19
|
+
let NET = "", USER = "", slot = 0;
|
|
20
|
+
|
|
21
|
+
function seedSchedule(createdByNodeId: string | null = null): any {
|
|
22
|
+
const id = `sched_offline_${crypto.randomUUID()}`;
|
|
23
|
+
db.run(
|
|
24
|
+
`INSERT INTO scheduled_tasks
|
|
25
|
+
(schedule_id, network_id, created_by, created_by_node_id, name, target_node_id, target_alias,
|
|
26
|
+
task_content, priority, schedule_type, schedule_json, timezone, status, next_run_at)
|
|
27
|
+
VALUES (?1, ?2, ?3, ?4, '离线巡检', ?5, ?6, 'ping', 'normal', 'interval',
|
|
28
|
+
'{"type":"interval","every_seconds":60}', 'Asia/Shanghai', 'active', datetime('now', '+1 minute'))`,
|
|
29
|
+
[id, NET, USER, createdByNodeId, TARGET_ID, TARGET],
|
|
30
|
+
);
|
|
31
|
+
return getScheduleRow(id)!;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function fire(schedule: any) {
|
|
35
|
+
const at = new Date(Date.UTC(2026, 9, 8, 0, 0, 0) + (++slot) * 60_000).toISOString();
|
|
36
|
+
return dispatchScheduledOccurrence(schedule, at, false, new Date());
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function setTarget(status: "idle" | "offline", minutesAgo = 0) {
|
|
40
|
+
const seen = new Date(Date.now() - minutesAgo * 60_000).toISOString().replace("T", " ").replace(/\.\d{3}Z$/, "");
|
|
41
|
+
db.run("UPDATE sessions SET status = ?1, updated_at = ?2, last_seen_at = ?2 WHERE node_id = ?3 AND network_id = ?4", [status, seen, TARGET_ID, NET]);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const taskCount = () => db.get<{ n: number }>("SELECT COUNT(*) AS n FROM tasks WHERE network_id = ?1 AND to_node_id = ?2", NET, TARGET_ID)!.n;
|
|
45
|
+
const humanNotices = (scheduleId: string) => db.all<{ meta_json: string; content: string }>(
|
|
46
|
+
"SELECT meta_json, content FROM user_inbox WHERE network_id = ?1 AND user_id = ?2 AND kind = ?3 ORDER BY created_at, message_id",
|
|
47
|
+
NET, USER, SCHEDULE_TARGET_OFFLINE_NOTICE_KIND,
|
|
48
|
+
).filter((row) => JSON.parse(row.meta_json).schedule_target_offline.schedule_id === scheduleId);
|
|
49
|
+
const agentNotices = (scheduleId: string) => db.all<{ meta_json: string; content: string }>(
|
|
50
|
+
"SELECT meta_json, content FROM inbox WHERE network_id = ?1 AND node_id = ?2 AND from_session = 'hub' ORDER BY created_at, id",
|
|
51
|
+
NET, CREATOR_ID,
|
|
52
|
+
).filter((row) => JSON.parse(row.meta_json).schedule_target_offline?.schedule_id === scheduleId);
|
|
53
|
+
|
|
54
|
+
beforeAll(() => {
|
|
55
|
+
const owner = register(`offline_owner_${stamp}`, "OfflineOwner123!", undefined, "seed");
|
|
56
|
+
expect(owner.ok).toBe(true);
|
|
57
|
+
NET = owner.network_id!; USER = owner.user!.user_id;
|
|
58
|
+
db.run("INSERT INTO nodes (node_id, node_name, alias, runtime, network_id, owner_user_id) VALUES (?1, ?2, ?2, 'codex', ?3, ?4)", [TARGET_ID, TARGET, NET, USER]);
|
|
59
|
+
db.run("INSERT INTO nodes (node_id, node_name, alias, runtime, network_id, owner_user_id) VALUES (?1, ?2, ?2, 'codex', ?3, ?4)", [CREATOR_ID, CREATOR, NET, USER]);
|
|
60
|
+
db.run("INSERT INTO sessions (resume_id, alias, status, node_id, network_id, last_seen_at) VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'))", [`r_target_${stamp}`, TARGET, TARGET_ID, NET]);
|
|
61
|
+
db.run("INSERT INTO sessions (resume_id, alias, status, node_id, network_id, last_seen_at) VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'))", [`r_creator_${stamp}`, CREATOR, CREATOR_ID, NET]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterAll(() => {
|
|
65
|
+
db.run("DELETE FROM user_inbox WHERE network_id = ?1 AND kind = ?2", [NET, SCHEDULE_TARGET_OFFLINE_NOTICE_KIND]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("#757 scheduled target offline", () => {
|
|
69
|
+
test("offline skips without a task, warns a human once, and online recovery dispatches normally", () => {
|
|
70
|
+
const schedule = seedSchedule();
|
|
71
|
+
const online = fire(schedule);
|
|
72
|
+
expect(online.status).toBe("delivered");
|
|
73
|
+
db.run("UPDATE tasks SET status = 'replied' WHERE task_id = ?1", [online.taskId]);
|
|
74
|
+
|
|
75
|
+
setTarget("offline");
|
|
76
|
+
const before = taskCount();
|
|
77
|
+
const first = fire(schedule), second = fire(schedule);
|
|
78
|
+
expect([first.status, second.status]).toEqual(["skipped", "skipped"]);
|
|
79
|
+
expect(taskCount()).toBe(before);
|
|
80
|
+
for (const run of [first, second]) {
|
|
81
|
+
expect(db.get("SELECT status, error_code, task_id FROM scheduled_task_runs WHERE run_id = ?1", run.runId))
|
|
82
|
+
.toEqual({ status: "skipped", error_code: "target_offline", task_id: null });
|
|
83
|
+
}
|
|
84
|
+
expect(humanNotices(schedule.schedule_id)).toHaveLength(1);
|
|
85
|
+
expect(humanNotices(schedule.schedule_id)[0].content).toContain("恢复在线后");
|
|
86
|
+
|
|
87
|
+
setTarget("idle");
|
|
88
|
+
const recovered = fire(schedule);
|
|
89
|
+
expect(recovered.status).toBe("delivered");
|
|
90
|
+
expect(recovered.taskId).toBeTruthy();
|
|
91
|
+
db.run("UPDATE tasks SET status = 'replied' WHERE task_id = ?1", [recovered.taskId]);
|
|
92
|
+
|
|
93
|
+
setTarget("offline");
|
|
94
|
+
expect(fire(schedule).status).toBe("skipped");
|
|
95
|
+
expect(humanNotices(schedule.schedule_id)).toHaveLength(2);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("an Agent-created schedule warns the creator inbox, never the owner's user inbox", () => {
|
|
99
|
+
const schedule = seedSchedule(CREATOR_ID);
|
|
100
|
+
setTarget("offline");
|
|
101
|
+
expect(fire(schedule).status).toBe("skipped");
|
|
102
|
+
expect(fire(schedule).status).toBe("skipped");
|
|
103
|
+
expect(agentNotices(schedule.schedule_id)).toHaveLength(1);
|
|
104
|
+
expect(agentNotices(schedule.schedule_id)[0].content).toContain("不需要回复");
|
|
105
|
+
expect(humanNotices(schedule.schedule_id)).toHaveLength(0);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("an idle session older than the existing five-minute cutoff is offline", () => {
|
|
109
|
+
const schedule = seedSchedule();
|
|
110
|
+
setTarget("idle", 6);
|
|
111
|
+
const before = taskCount();
|
|
112
|
+
const run = fire(schedule);
|
|
113
|
+
expect(run.status).toBe("skipped");
|
|
114
|
+
expect(taskCount()).toBe(before);
|
|
115
|
+
expect(db.get<{ error_code: string }>("SELECT error_code FROM scheduled_task_runs WHERE run_id = ?1", run.runId)?.error_code).toBe("target_offline");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("recent SQLite and ISO timestamps all keep an idle target online", () => {
|
|
119
|
+
const now = Date.now();
|
|
120
|
+
const isoMs = new Date(now - 1_000).toISOString();
|
|
121
|
+
const values = [
|
|
122
|
+
isoMs.slice(0, 19).replace("T", " "),
|
|
123
|
+
`${isoMs.slice(0, 19)}Z`,
|
|
124
|
+
isoMs,
|
|
125
|
+
];
|
|
126
|
+
for (const value of values) {
|
|
127
|
+
db.run("UPDATE sessions SET status = 'idle', last_seen_at = ?1 WHERE node_id = ?2 AND network_id = ?3", [value, TARGET_ID, NET]);
|
|
128
|
+
expect(scheduledTargetOffline(TARGET_ID, TARGET, NET, now)).toBe(false);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { sendAgentNotice } from "./agent-notice.js";
|
|
2
|
+
import { db, uuidv4 } from "./db.js";
|
|
3
|
+
import { parseDbTimestampMs } from "./db-timestamp.js";
|
|
4
|
+
import { pendingInboxCount } from "./inbox-count.js";
|
|
5
|
+
import { pushEvent } from "./push.js";
|
|
6
|
+
|
|
7
|
+
export const SCHEDULE_TARGET_OFFLINE_NOTICE_KIND = "schedule_target_offline";
|
|
8
|
+
const SESSION_STALE_MS = 5 * 60_000;
|
|
9
|
+
|
|
10
|
+
type SessionRow = { status: string | null; updated_at: string | null; last_seen_at: string | null };
|
|
11
|
+
|
|
12
|
+
/** Same status + five-minute timestamp rule as the existing task delivery resolver. */
|
|
13
|
+
export function scheduledTargetOffline(nodeId: string, alias: string, networkId: string, nowMs = Date.now()): boolean {
|
|
14
|
+
const session = db.get<SessionRow>(
|
|
15
|
+
"SELECT status, updated_at, last_seen_at FROM sessions WHERE node_id = ?1 AND network_id = ?2 ORDER BY updated_at DESC LIMIT 1",
|
|
16
|
+
nodeId, networkId,
|
|
17
|
+
) ?? db.get<SessionRow>(
|
|
18
|
+
"SELECT status, updated_at, last_seen_at FROM sessions WHERE alias = ?1 AND network_id = ?2 AND node_id IS NULL ORDER BY updated_at DESC LIMIT 1",
|
|
19
|
+
alias, networkId,
|
|
20
|
+
);
|
|
21
|
+
if (!session) return true;
|
|
22
|
+
const raw = session.last_seen_at || session.updated_at;
|
|
23
|
+
const seen = raw ? parseDbTimestampMs(raw) : 0;
|
|
24
|
+
return String(session.status || "").toLowerCase() === "offline" || !Number.isFinite(seen) || nowMs - seen > SESSION_STALE_MS;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type OfflineNotice = {
|
|
28
|
+
scheduleId: string; scheduleName: string; networkId: string;
|
|
29
|
+
createdBy: string | null; createdByNodeId: string | null;
|
|
30
|
+
targetAlias: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function noticeText(n: OfflineNotice): string {
|
|
34
|
+
return `定时任务「${n.scheduleName}」的目标节点 ${n.targetAlias} 已离线,本轮已跳过且没有创建任务。节点恢复在线后,后续轮次会自动照常派发。`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Post-commit delivery. The transaction has already claimed this offline episode. */
|
|
38
|
+
export function sendOfflineNotice(n: OfflineNotice): string | null {
|
|
39
|
+
try {
|
|
40
|
+
const text = noticeText(n);
|
|
41
|
+
const meta = { schedule_target_offline: { schedule_id: n.scheduleId, target_alias: n.targetAlias, reason: "target_offline" } };
|
|
42
|
+
if (!n.createdByNodeId) {
|
|
43
|
+
if (!n.createdBy) return null;
|
|
44
|
+
return sendAgentNotice({
|
|
45
|
+
networkId: n.networkId, userId: n.createdBy, fromAlias: n.targetAlias,
|
|
46
|
+
kind: SCHEDULE_TARGET_OFFLINE_NOTICE_KIND, title: "定时任务目标节点离线", text,
|
|
47
|
+
severity: "warning", meta, idPrefix: "dm_sched_offline_",
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const creator = db.get<{ alias: string | null }>(
|
|
51
|
+
"SELECT alias FROM nodes WHERE node_id = ?1 AND network_id = ?2", n.createdByNodeId, n.networkId,
|
|
52
|
+
);
|
|
53
|
+
if (!creator?.alias) return null;
|
|
54
|
+
const id = `sched_offline_${uuidv4()}`;
|
|
55
|
+
db.run(
|
|
56
|
+
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id, meta_json)
|
|
57
|
+
VALUES (?1, ?2, ?3, 'message', 'normal', ?4, 'hub', ?5, ?6)`,
|
|
58
|
+
[id, creator.alias, n.createdByNodeId, `[定时任务目标节点离线] ${text}\n(这是告警,不需要回复。)`, n.networkId, JSON.stringify(meta)],
|
|
59
|
+
);
|
|
60
|
+
pushEvent(creator.alias, { type: "new_message", inbox_count: pendingInboxCount(creator.alias, n.networkId), from: "hub", message_id: id }, n.networkId);
|
|
61
|
+
return id;
|
|
62
|
+
} catch (e: any) {
|
|
63
|
+
console.error(`[scheduled-tasks] offline notice failed schedule=${n.scheduleId}: ${e?.message || e}`);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
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();
|