@sleep2agi/commhub-server 0.9.0-preview.1 → 0.9.0-preview.101
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/README.md +130 -10
- package/bin/commhub.ts +92 -11
- package/package.json +8 -7
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +487 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +520 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +190 -0
- package/src/agent-notice.ts +48 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +42 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-nodes-shape.test.ts +188 -0
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +493 -35
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/config-apply-sec1.test.ts +270 -1
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +275 -0
- package/src/create-node-validate.ts +288 -0
- package/src/create-node-workdir.test.ts +255 -0
- package/src/create-node.test.ts +354 -0
- package/src/create-node.ts +349 -0
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +148 -0
- package/src/db-adapter-translate.test.ts +128 -0
- package/src/db-adapter.ts +323 -87
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +984 -34
- package/src/delivered-without-push.test.ts +140 -0
- package/src/department-groups-http.test.ts +209 -0
- package/src/department-groups-sync-http.test.ts +328 -0
- package/src/department-groups.ts +434 -0
- package/src/department-heads-http.test.ts +329 -0
- package/src/department-heads.ts +226 -0
- package/src/departments-http.test.ts +185 -0
- package/src/departments.ts +260 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/dm-file-scope-http.test.ts +178 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +732 -0
- package/src/group-membership-events-http.test.ts +264 -0
- package/src/group-messages-http.test.ts +482 -0
- package/src/group-messages.ts +297 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +94 -0
- package/src/http-gzip.ts +112 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +215 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2172
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/mcp-context-cost-http.test.ts +119 -0
- package/src/mcp-projects-events-http.test.ts +162 -0
- package/src/mcp-route-label-http.test.ts +120 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +206 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/migrate-sqlite-to-pg.test.ts +47 -0
- package/src/migrate-sqlite-to-pg.ts +263 -0
- package/src/model-auth-notify-http.test.ts +181 -0
- package/src/model-auth-notify.ts +116 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +224 -0
- package/src/network-token-membership-http.test.ts +139 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-create-request-status-http.test.ts +92 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-health-dispatch-http.test.ts +279 -0
- package/src/node-health-guard.ts +105 -0
- package/src/node-health-passthrough.test.ts +115 -0
- package/src/node-health-store.ts +72 -0
- package/src/node-id-alias.test.ts +188 -0
- package/src/node-identity-conflict-http.test.ts +269 -0
- package/src/node-identity-conflict.ts +142 -0
- package/src/node-identity-mismatch.test.ts +74 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-permissions-http.test.ts +276 -0
- package/src/node-permissions.ts +242 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +431 -0
- package/src/observer-push.test.ts +252 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/pg-worker.ts +87 -0
- package/src/probe-validate.test.ts +221 -0
- package/src/probe-validate.ts +104 -0
- package/src/probe.test.ts +80 -0
- package/src/probe.ts +200 -0
- package/src/project-viewer-can-http.test.ts +114 -0
- package/src/push.ts +429 -10
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +7 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-host-schema.test.ts +211 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-due-reminders-http.test.ts +518 -0
- package/src/requirement-due-reminders.ts +369 -0
- package/src/requirement-events-http.test.ts +257 -0
- package/src/requirement-events.test.ts +45 -0
- package/src/requirement-events.ts +144 -0
- package/src/requirement-notify.ts +165 -0
- package/src/requirement-participant-notify-http.test.ts +309 -0
- package/src/requirement-tag-ops-http.test.ts +266 -0
- package/src/requirement-tags.ts +89 -0
- package/src/requirements-comment-http.test.ts +165 -0
- package/src/requirements-completed-at-migration.test.ts +70 -0
- package/src/requirements-errors-mcp-http.test.ts +164 -0
- package/src/requirements-errors.ts +143 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +709 -0
- package/src/requirements-last-event-http.test.ts +344 -0
- package/src/requirements-list-mcp-slim-http.test.ts +155 -0
- package/src/requirements-list-slim-http.test.ts +319 -0
- package/src/requirements-mcp.test.ts +163 -0
- package/src/requirements-migrate.ts +225 -0
- package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
- package/src/requirements-people-http.test.ts +114 -0
- package/src/requirements-people-mcp-http.test.ts +225 -0
- package/src/requirements-people.ts +127 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-search-http.test.ts +182 -0
- package/src/requirements-search.test.ts +32 -0
- package/src/requirements-search.ts +145 -0
- package/src/requirements-seq-http.test.ts +193 -0
- package/src/requirements-seq-migration.test.ts +63 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-stats-http.test.ts +288 -0
- package/src/requirements-stats.ts +164 -0
- package/src/requirements-status-participants-http.test.ts +220 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1761 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +222 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +73 -0
- package/src/retention.ts +15 -3
- package/src/route-callers-http.test.ts +120 -0
- package/src/route-timing.test.ts +211 -0
- package/src/route-timing.ts +258 -0
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
- package/src/scheduled-failures-http.test.ts +233 -0
- package/src/scheduled-failures.ts +179 -0
- package/src/scheduled-reply-unread-http.test.ts +212 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-stuck-http.test.ts +238 -0
- package/src/scheduled-stuck.ts +153 -0
- package/src/scheduled-tasks-http.test.ts +638 -0
- package/src/scheduled-tasks.ts +650 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +5038 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
- package/src/shared/probe-host-allowlist.ts +128 -0
- package/src/shared/reserved-env-drift.test.ts +43 -0
- package/src/shared/reserved-env.test.ts +35 -0
- package/src/shared/reserved-env.ts +31 -0
- package/src/side-thread-command-transport.test.ts +312 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2499 -0
- package/src/skillhub-http.test.ts +108 -0
- package/src/sqlite-close-checkpoint.test.ts +78 -0
- package/src/start-node.test.ts +109 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/status-alias-resolver-http.test.ts +163 -0
- package/src/status-alias-resolver.ts +19 -0
- package/src/status-node-id-filter-http.test.ts +161 -0
- package/src/status-read-cache-http.test.ts +229 -0
- package/src/status-read-cache.ts +122 -0
- package/src/stop-delete-node.test.ts +1164 -0
- package/src/task-access-http.test.ts +379 -0
- package/src/task-access.ts +413 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-expiry-notice-http.test.ts +287 -0
- package/src/task-expiry-notice.ts +313 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/task-queue-ahead-http.test.ts +309 -0
- package/src/task-queue-ahead-scale.test.ts +211 -0
- package/src/task-queue-ahead.ts +186 -0
- package/src/task-stale-open-http.test.ts +290 -0
- package/src/task-stale-open.ts +103 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tool-audience-http.test.ts +195 -0
- package/src/tool-audience.ts +109 -0
- package/src/tools-list-json-schema.test.ts +67 -0
- package/src/tools.ts +4955 -300
- package/src/update-provider.test.ts +234 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +138 -5
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// #523 —— 定时任务连续失败:告诉创建者(可选自动暂停)。
|
|
2
|
+
//
|
|
3
|
+
// 现场:一个每 2 分钟一次的排程,每一次派出去的任务都被节点以 failed 结束(上游模型拒绝),一天 720 次,
|
|
4
|
+
// 排程照样 active,创建者什么都不知道。#464 只管「被上一次卡住」(skipped),不管「每次都失败」。
|
|
5
|
+
//
|
|
6
|
+
// 1. 计数:consecutive_failures = 最近一次成功(run.status='replied')之后,status='failed' 的 run 数。
|
|
7
|
+
// skipped / cancelled / expired / 还没结束的 run 不算失败,也不打断连续(expired 由 #464 的超时告警负责)。
|
|
8
|
+
// 失败有两种来源,都会走到这里:派发时就失败(节点不在 / 被停用 / 降级 / 创建者无权限,
|
|
9
|
+
// scheduled-tasks.ts 提交后调用),以及派出去的任务以 failed 结束(db.ts 的 run 镜像触发钩子)。
|
|
10
|
+
// 2. 告警:连续失败 ≥ N(默认 5)→ 给排程创建者发**一条**通知(agent-notice.ts,与 #462/#464 同一条路,
|
|
11
|
+
// 出现在与目标节点的会话里)。去重落在 scheduled_tasks 两列上(条件写,一次只有一个调用方抢得到):
|
|
12
|
+
// failure_alert_key = 告警那一刻「最近一次成功」的 run_id —— 之后又成功过,key 就变了,下一段失败重新上膛;
|
|
13
|
+
// failure_alert_at = 告警时间 —— 一直没成功过,24 小时后再提醒一次。Hub 重启不会重发。
|
|
14
|
+
// 3. 自动暂停:默认关。COMMHUB_SCHEDULE_FAILURE_AUTO_PAUSE_RUNS=M(≥1)时,连续失败 ≥ M 的 active 排程
|
|
15
|
+
// 被置为 paused(next_run_at 清空,revision+1,与用户手动暂停同形),并通知创建者。
|
|
16
|
+
//
|
|
17
|
+
// 环境变量:COMMHUB_SCHEDULE_FAILURE_NOTICE_RUNS(N,默认 5)、COMMHUB_SCHEDULE_FAILURE_RENOTICE_SEC
|
|
18
|
+
// (默认 86400)、COMMHUB_SCHEDULE_FAILURE_AUTO_PAUSE_RUNS(M,默认 0 = 关)。
|
|
19
|
+
|
|
20
|
+
import { db, setScheduledRunTerminalHook } from "./db.js";
|
|
21
|
+
import { sendAgentNotice } from "./agent-notice.js";
|
|
22
|
+
|
|
23
|
+
export const SCHEDULE_FAILING_NOTICE_KIND = "schedule_failing";
|
|
24
|
+
const ERROR_PREVIEW_CHARS = 300;
|
|
25
|
+
|
|
26
|
+
function envInt(name: string, fallback: number, min: number): number {
|
|
27
|
+
const raw = process.env[name];
|
|
28
|
+
if (raw === undefined || raw.trim() === "") return fallback;
|
|
29
|
+
const n = Number(raw);
|
|
30
|
+
return Number.isFinite(n) && n >= min ? Math.floor(n) : fallback;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function failureNoticeRuns(): number { return envInt("COMMHUB_SCHEDULE_FAILURE_NOTICE_RUNS", 5, 1); }
|
|
34
|
+
export function failureRenoticeMs(): number { return envInt("COMMHUB_SCHEDULE_FAILURE_RENOTICE_SEC", 86_400, 1) * 1000; }
|
|
35
|
+
/** 0 = 不自动暂停(默认)。 */
|
|
36
|
+
export function failureAutoPauseRuns(): number { return envInt("COMMHUB_SCHEDULE_FAILURE_AUTO_PAUSE_RUNS", 0, 0); }
|
|
37
|
+
|
|
38
|
+
export type FailureStreak = { consecutiveFailures: number; lastSuccessRunId: string | null };
|
|
39
|
+
|
|
40
|
+
/** 最近一次成功之后的失败 run 数。scheduled_for 每个排程唯一(UNIQUE),按它排序是确定的。 */
|
|
41
|
+
export function failureStreak(scheduleId: string): FailureStreak {
|
|
42
|
+
const lastSuccess = db.get<{ run_id: string; scheduled_for: string }>(
|
|
43
|
+
"SELECT run_id, scheduled_for FROM scheduled_task_runs WHERE schedule_id = ?1 AND status = 'replied' ORDER BY scheduled_for DESC LIMIT 1",
|
|
44
|
+
scheduleId,
|
|
45
|
+
);
|
|
46
|
+
const n = db.get<{ n: number }>(
|
|
47
|
+
"SELECT COUNT(*) AS n FROM scheduled_task_runs WHERE schedule_id = ?1 AND status = 'failed' AND scheduled_for > ?2",
|
|
48
|
+
scheduleId, lastSuccess?.scheduled_for ?? "",
|
|
49
|
+
)?.n ?? 0;
|
|
50
|
+
return { consecutiveFailures: Number(n), lastSuccessRunId: lastSuccess?.run_id ?? null };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type ScheduleForAlert = {
|
|
54
|
+
schedule_id: string;
|
|
55
|
+
network_id: string;
|
|
56
|
+
created_by: string | null;
|
|
57
|
+
name: string;
|
|
58
|
+
target_alias: string;
|
|
59
|
+
status: string;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export type FailureNotice = {
|
|
63
|
+
reason: "failing" | "paused";
|
|
64
|
+
scheduleId: string;
|
|
65
|
+
scheduleName: string;
|
|
66
|
+
networkId: string;
|
|
67
|
+
createdBy: string;
|
|
68
|
+
alias: string;
|
|
69
|
+
failures: number;
|
|
70
|
+
errorCode: string | null;
|
|
71
|
+
errorMessage: string | null;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
function latestFailure(scheduleId: string): { error_code: string | null; error_message: string | null } {
|
|
75
|
+
const row = db.get<{ error_code: string | null; error_message: string | null; result: string | null }>(
|
|
76
|
+
`SELECT r.error_code, r.error_message, t.result
|
|
77
|
+
FROM scheduled_task_runs r
|
|
78
|
+
LEFT JOIN tasks t ON t.task_id = r.task_id AND t.network_id = r.network_id
|
|
79
|
+
WHERE r.schedule_id = ?1 AND r.status = 'failed'
|
|
80
|
+
ORDER BY r.scheduled_for DESC LIMIT 1`,
|
|
81
|
+
scheduleId,
|
|
82
|
+
);
|
|
83
|
+
// 任务以 failed 结束时 run.error_message 为空,原因在任务的回复正文里。
|
|
84
|
+
return { error_code: row?.error_code ?? null, error_message: row?.error_message || row?.result || null };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function truncate(s: string, max: number): string {
|
|
88
|
+
const flat = s.replace(/\s+/g, " ").trim();
|
|
89
|
+
return flat.length > max ? `${flat.slice(0, max)}…` : flat;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function failureNoticeText(n: FailureNotice): { title: string; text: string } {
|
|
93
|
+
const err = n.errorMessage ? truncate(n.errorMessage, ERROR_PREVIEW_CHARS) : "(没有错误详情)";
|
|
94
|
+
const code = n.errorCode ? ` [${n.errorCode}]` : "";
|
|
95
|
+
const head = `定时任务「${n.scheduleName}」(派给节点 ${n.alias})已经连续 ${n.failures} 次执行失败。\n\n最近一次的错误${code}:${err}\n\n`;
|
|
96
|
+
if (n.reason === "paused") {
|
|
97
|
+
return {
|
|
98
|
+
title: "定时任务连续失败,已自动暂停",
|
|
99
|
+
text: `${head}按 Hub 的设置,它已被自动暂停,不会再派发。处理好原因后,在定时任务里把它恢复为「启用」即可。`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
title: "定时任务连续失败",
|
|
104
|
+
text: `${head}它仍在按计划运行。如需停止,在定时任务里暂停它(或 PATCH /api/scheduled-tasks/${n.scheduleId},body 带 {"status":"paused","revision":<当前 revision>})。`
|
|
105
|
+
+ `在它重新成功之前,24 小时内不会再提醒。`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function sendFailureNotice(n: FailureNotice): string | null {
|
|
110
|
+
try {
|
|
111
|
+
const { title, text } = failureNoticeText(n);
|
|
112
|
+
return sendAgentNotice({
|
|
113
|
+
networkId: n.networkId, userId: n.createdBy, fromAlias: n.alias, kind: SCHEDULE_FAILING_NOTICE_KIND, title, text, severity: "error",
|
|
114
|
+
meta: { schedule_failing: { reason: n.reason, schedule_id: n.scheduleId, consecutive_failures: n.failures, error_code: n.errorCode } },
|
|
115
|
+
idPrefix: "dm_sched_",
|
|
116
|
+
});
|
|
117
|
+
} catch (e: any) {
|
|
118
|
+
console.error(`[scheduled-tasks] failure notice failed schedule=${n.scheduleId}: ${e?.message || e}`);
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 一个 run 以 failed 结束之后调用(派发时失败 / 任务 failed 都算)。返回发出的通知(测试用),没有则 null。
|
|
125
|
+
* 告警与暂停都是条件写:并发的两个调用方只有一个写得进去,所以每段失败恰好一条通知。
|
|
126
|
+
*/
|
|
127
|
+
export function evaluateScheduleFailures(scheduleId: string, now = new Date()): FailureNotice | null {
|
|
128
|
+
const row = db.get<ScheduleForAlert>(
|
|
129
|
+
"SELECT schedule_id, network_id, created_by, name, target_alias, status FROM scheduled_tasks WHERE schedule_id = ?1",
|
|
130
|
+
scheduleId,
|
|
131
|
+
);
|
|
132
|
+
if (!row) return null;
|
|
133
|
+
const streak = failureStreak(scheduleId);
|
|
134
|
+
const noticeAt = failureNoticeRuns();
|
|
135
|
+
const pauseAt = failureAutoPauseRuns();
|
|
136
|
+
const wantsPause = pauseAt > 0 && streak.consecutiveFailures >= pauseAt && row.status === "active";
|
|
137
|
+
if (streak.consecutiveFailures < noticeAt && !wantsPause) return null;
|
|
138
|
+
|
|
139
|
+
let paused = false;
|
|
140
|
+
if (wantsPause) {
|
|
141
|
+
paused = db.run(
|
|
142
|
+
"UPDATE scheduled_tasks SET status = 'paused', next_run_at = NULL, revision = revision + 1, updated_at = datetime('now') WHERE schedule_id = ?1 AND status = 'active'",
|
|
143
|
+
[scheduleId],
|
|
144
|
+
).changes === 1;
|
|
145
|
+
}
|
|
146
|
+
const key = streak.lastSuccessRunId ?? "";
|
|
147
|
+
const cutoff = new Date(now.getTime() - failureRenoticeMs()).toISOString();
|
|
148
|
+
// 去重:同一段失败(key 没变)且上次告警不到 24 小时 → 不写、不发。暂停是另一件事,照发。
|
|
149
|
+
const claimed = db.run(
|
|
150
|
+
`UPDATE scheduled_tasks SET failure_alert_at = ?1, failure_alert_key = ?2
|
|
151
|
+
WHERE schedule_id = ?3
|
|
152
|
+
AND (failure_alert_at IS NULL OR COALESCE(failure_alert_key, '') <> ?2 OR failure_alert_at <= ?4)`,
|
|
153
|
+
[now.toISOString(), key, scheduleId, cutoff],
|
|
154
|
+
).changes === 1;
|
|
155
|
+
if (paused && !claimed) {
|
|
156
|
+
db.run("UPDATE scheduled_tasks SET failure_alert_at = ?1, failure_alert_key = ?2 WHERE schedule_id = ?3", [now.toISOString(), key, scheduleId]);
|
|
157
|
+
}
|
|
158
|
+
if (!paused && !claimed) return null;
|
|
159
|
+
if (!row.created_by) return null;
|
|
160
|
+
const latest = latestFailure(scheduleId);
|
|
161
|
+
const notice: FailureNotice = {
|
|
162
|
+
reason: paused ? "paused" : "failing",
|
|
163
|
+
scheduleId, scheduleName: row.name, networkId: row.network_id, createdBy: row.created_by, alias: row.target_alias,
|
|
164
|
+
failures: streak.consecutiveFailures, errorCode: latest.error_code, errorMessage: latest.error_message,
|
|
165
|
+
};
|
|
166
|
+
sendFailureNotice(notice);
|
|
167
|
+
return notice;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** GET /runs 的附加字段。 */
|
|
171
|
+
export function failureSummary(scheduleId: string): { consecutive_failures: number; failure_alert_threshold: number; last_failure_alert_at: string | null } {
|
|
172
|
+
const streak = failureStreak(scheduleId);
|
|
173
|
+
const alertAt = db.get<{ failure_alert_at: string | null }>("SELECT failure_alert_at FROM scheduled_tasks WHERE schedule_id = ?1", scheduleId)?.failure_alert_at ?? null;
|
|
174
|
+
return { consecutive_failures: streak.consecutiveFailures, failure_alert_threshold: failureNoticeRuns(), last_failure_alert_at: alertAt };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
setScheduledRunTerminalHook((scheduleId, status) => {
|
|
178
|
+
if (status === "failed") evaluateScheduleFailures(scheduleId);
|
|
179
|
+
});
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// 定时任务的回复要进建排程那个人的未读(Vincent 2026-09-30「定时任务触发的…那个红点它是不显示的」)。
|
|
2
|
+
//
|
|
3
|
+
// scheduler 派的任务 from_name='scheduler',回复以前按 from_name 落进 inbox(session_name='scheduler'):
|
|
4
|
+
// app 会话里看得到(会话取自 tasks 表),但 unread_by_agent 只数 session_name=用户名 的行 ⇒ 永远没红点。
|
|
5
|
+
// 这里走真 Hub(bootServer)+ 真节点令牌回复路径(send_reply,callerTokenIsNetwork=true)。
|
|
6
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
|
+
import { addNetworkMember, createNetworkTokenForNode, register } from "./auth.js";
|
|
9
|
+
import { db } from "./db.js";
|
|
10
|
+
import { runDueScheduledTasks } from "./scheduled-tasks.js";
|
|
11
|
+
import { registerTools } from "./tools.js";
|
|
12
|
+
|
|
13
|
+
const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
|
|
14
|
+
if (!activeDbPath) throw new Error("scheduled-reply-unread requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
|
|
15
|
+
|
|
16
|
+
let server: any;
|
|
17
|
+
let base = "";
|
|
18
|
+
let ownerToken = "";
|
|
19
|
+
let ownerName = "";
|
|
20
|
+
let memberToken = "";
|
|
21
|
+
let memberName = "";
|
|
22
|
+
let networkId = "";
|
|
23
|
+
let ownerId = "";
|
|
24
|
+
const stamp = Date.now();
|
|
25
|
+
const ALIAS = `sched-reply-node-${stamp}`;
|
|
26
|
+
const nodeId = `n_sched_reply_${stamp}`;
|
|
27
|
+
|
|
28
|
+
async function api(token: string, path: string, init?: RequestInit) {
|
|
29
|
+
const res = await fetch(`${base}${path}`, {
|
|
30
|
+
...init,
|
|
31
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(init?.headers || {}) },
|
|
32
|
+
});
|
|
33
|
+
return { status: res.status, body: await res.json() as any };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 节点令牌身份调 send_reply(与真节点的 commhub_reply 同一个 handler、同一组归属校验)。 */
|
|
37
|
+
async function nodeReply(args: Record<string, unknown>): Promise<any> {
|
|
38
|
+
const mcp = new McpServer({ name: "sched-reply", version: "0" }) as any;
|
|
39
|
+
const handlers: Record<string, (a: any) => Promise<any>> = {};
|
|
40
|
+
const original = mcp.tool.bind(mcp);
|
|
41
|
+
mcp.tool = (name: string, ...rest: any[]) => {
|
|
42
|
+
const handler = rest.at(-1);
|
|
43
|
+
if (typeof handler === "function") handlers[name] = handler;
|
|
44
|
+
return original(name, ...rest);
|
|
45
|
+
};
|
|
46
|
+
registerTools(mcp, undefined, networkId, ownerId, ALIAS, true, null);
|
|
47
|
+
const out = await handlers.send_reply(args);
|
|
48
|
+
return JSON.parse(out.content[0].text);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function unreadFor(token: string): Promise<Record<string, number>> {
|
|
52
|
+
const res = await api(token, "/api/messages?scope=user&limit=50");
|
|
53
|
+
expect(res.status).toBe(200);
|
|
54
|
+
return res.body.unread_by_agent ?? {};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function ackAgent(token: string) {
|
|
58
|
+
const res = await api(token, "/api/messages/ack", { method: "POST", body: JSON.stringify({ agent: ALIAS }) });
|
|
59
|
+
expect(res.status).toBe(200);
|
|
60
|
+
return res.body;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 建一个排程、让它立刻到期、跑一次,返回它派出的 task_id。 */
|
|
64
|
+
async function fireSchedule(token: string, name: string): Promise<{ scheduleId: string; taskId: string }> {
|
|
65
|
+
const created = await api(token, "/api/scheduled-tasks", {
|
|
66
|
+
method: "POST",
|
|
67
|
+
body: JSON.stringify({
|
|
68
|
+
network_id: networkId, name, target_node_id: nodeId, task: `${name} tick`,
|
|
69
|
+
timezone: "UTC", schedule: { type: "interval", every_seconds: 120 },
|
|
70
|
+
}),
|
|
71
|
+
});
|
|
72
|
+
expect(created.status).toBe(201);
|
|
73
|
+
const scheduleId = created.body.schedule.schedule_id as string;
|
|
74
|
+
// 别的排程不许在这次 sweep 里一起跑
|
|
75
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id != ?2", [new Date(Date.now() + 24 * 3600_000).toISOString(), scheduleId]);
|
|
76
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [new Date(Date.now() - 60_000).toISOString(), scheduleId]);
|
|
77
|
+
await runDueScheduledTasks();
|
|
78
|
+
const run = db.get<{ task_id: string | null }>(
|
|
79
|
+
"SELECT task_id FROM scheduled_task_runs WHERE schedule_id = ?1 ORDER BY scheduled_for DESC LIMIT 1", scheduleId,
|
|
80
|
+
);
|
|
81
|
+
expect(run?.task_id).toBeTruthy();
|
|
82
|
+
const task = db.get<{ from_name: string }>("SELECT from_name FROM tasks WHERE task_id = ?1", run!.task_id!);
|
|
83
|
+
expect(task?.from_name).toBe("scheduler");
|
|
84
|
+
return { scheduleId, taskId: run!.task_id! };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function replyRow(taskId: string) {
|
|
88
|
+
return db.get<{ session_name: string; from_session: string; acked: number }>(
|
|
89
|
+
"SELECT session_name, from_session, acked FROM inbox WHERE type = 'reply' AND in_reply_to = ?1", taskId,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
beforeAll(async () => {
|
|
94
|
+
ownerName = `sched_reply_owner_${stamp}`;
|
|
95
|
+
const owner = register(ownerName, "SchedReplyOwner123!", undefined, "seed");
|
|
96
|
+
expect(owner.ok).toBe(true);
|
|
97
|
+
ownerToken = owner.token!;
|
|
98
|
+
networkId = owner.network_id!;
|
|
99
|
+
ownerId = db.get<{ owner_id: string }>("SELECT owner_id FROM networks WHERE network_id = ?1", networkId)!.owner_id;
|
|
100
|
+
|
|
101
|
+
memberName = `sched_reply_member_${stamp}`;
|
|
102
|
+
const member = register(memberName, "SchedReplyMember123!", undefined, "seed");
|
|
103
|
+
expect(member.ok).toBe(true);
|
|
104
|
+
memberToken = member.token!;
|
|
105
|
+
const memberId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", memberName)!.user_id;
|
|
106
|
+
expect(addNetworkMember(networkId, memberId, "member", ownerId, { agentAccess: "all" }).ok).toBe(true);
|
|
107
|
+
|
|
108
|
+
expect(createNetworkTokenForNode(ownerId, networkId, ALIAS).ok).toBe(true);
|
|
109
|
+
db.run(
|
|
110
|
+
"INSERT INTO nodes (node_id, node_name, alias, runtime, network_id) VALUES (?1, ?2, ?2, 'claude-code', ?3)",
|
|
111
|
+
[nodeId, ALIAS, networkId],
|
|
112
|
+
);
|
|
113
|
+
db.run(
|
|
114
|
+
"INSERT INTO sessions (resume_id, alias, status, node_id, network_id) VALUES (?1, ?2, 'idle', ?3, ?4)",
|
|
115
|
+
[`r_sched_reply_${stamp}`, ALIAS, nodeId, networkId],
|
|
116
|
+
);
|
|
117
|
+
const mod: any = await import("./server.js");
|
|
118
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
119
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
120
|
+
}, 30_000);
|
|
121
|
+
|
|
122
|
+
afterAll(() => {
|
|
123
|
+
try { server?.stop?.(true); } catch {}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("scheduled-task replies reach the schedule creator's unread", () => {
|
|
127
|
+
test("a direct reply still lands on the sender and counts once", async () => {
|
|
128
|
+
await ackAgent(ownerToken);
|
|
129
|
+
const sent = await api(ownerToken, "/api/task", { method: "POST", body: JSON.stringify({ network_id: networkId, alias: ALIAS, task: "direct hello" }) });
|
|
130
|
+
expect(sent.status).toBe(200);
|
|
131
|
+
const taskId = sent.body.task_id as string;
|
|
132
|
+
const reply = await nodeReply({ in_reply_to: taskId, text: "direct answer", status: "replied" });
|
|
133
|
+
expect(reply.ok).toBe(true);
|
|
134
|
+
expect(replyRow(taskId)?.session_name).toBe(ownerName);
|
|
135
|
+
expect((await unreadFor(ownerToken))[ALIAS]).toBe(1);
|
|
136
|
+
await ackAgent(ownerToken);
|
|
137
|
+
expect((await unreadFor(ownerToken))[ALIAS]).toBeUndefined();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("a scheduled-task reply counts for the creator, and ack-by-agent clears it", async () => {
|
|
141
|
+
await ackAgent(ownerToken);
|
|
142
|
+
const { taskId } = await fireSchedule(ownerToken, "owner-every-2m");
|
|
143
|
+
// 节点不带 alias(SDK 运行时)——收件人由 in_reply_to 推出
|
|
144
|
+
const reply = await nodeReply({ in_reply_to: taskId, text: "tick answer", status: "replied" });
|
|
145
|
+
expect(reply.ok).toBe(true);
|
|
146
|
+
expect(replyRow(taskId)).toEqual({ session_name: ownerName, from_session: ALIAS, acked: 0 });
|
|
147
|
+
expect(db.get<{ status: string; result: string }>("SELECT status, result FROM tasks WHERE task_id = ?1", taskId))
|
|
148
|
+
.toEqual({ status: "replied", result: "tick answer" });
|
|
149
|
+
expect((await unreadFor(ownerToken))[ALIAS]).toBe(1);
|
|
150
|
+
|
|
151
|
+
const acked = await ackAgent(ownerToken);
|
|
152
|
+
expect(acked.acked_inbox).toBe(1);
|
|
153
|
+
expect((await unreadFor(ownerToken))[ALIAS]).toBeUndefined();
|
|
154
|
+
expect(replyRow(taskId)?.acked).toBe(1);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("a node that names alias='scheduler' explicitly is routed the same way", async () => {
|
|
158
|
+
await ackAgent(ownerToken);
|
|
159
|
+
const { taskId } = await fireSchedule(ownerToken, "owner-explicit-alias");
|
|
160
|
+
const reply = await nodeReply({ alias: "scheduler", in_reply_to: taskId, text: "explicit answer", status: "replied" });
|
|
161
|
+
expect(reply.ok).toBe(true);
|
|
162
|
+
expect(replyRow(taskId)?.session_name).toBe(ownerName);
|
|
163
|
+
expect((await unreadFor(ownerToken))[ALIAS]).toBe(1);
|
|
164
|
+
await ackAgent(ownerToken);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("another member of the network does not get the owner's scheduled reply, and gets their own", async () => {
|
|
168
|
+
await ackAgent(ownerToken);
|
|
169
|
+
await ackAgent(memberToken);
|
|
170
|
+
const ownerRun = await fireSchedule(ownerToken, "owner-only");
|
|
171
|
+
expect((await nodeReply({ in_reply_to: ownerRun.taskId, text: "for owner", status: "replied" })).ok).toBe(true);
|
|
172
|
+
expect((await unreadFor(memberToken))[ALIAS]).toBeUndefined();
|
|
173
|
+
expect((await unreadFor(ownerToken))[ALIAS]).toBe(1);
|
|
174
|
+
|
|
175
|
+
const memberRun = await fireSchedule(memberToken, "member-only");
|
|
176
|
+
expect((await nodeReply({ in_reply_to: memberRun.taskId, text: "for member", status: "replied" })).ok).toBe(true);
|
|
177
|
+
expect(replyRow(memberRun.taskId)?.session_name).toBe(memberName);
|
|
178
|
+
expect((await unreadFor(memberToken))[ALIAS]).toBe(1);
|
|
179
|
+
expect((await unreadFor(ownerToken))[ALIAS]).toBe(1);
|
|
180
|
+
await ackAgent(ownerToken);
|
|
181
|
+
await ackAgent(memberToken);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("a schedule with no creator falls back to 'scheduler'", async () => {
|
|
185
|
+
await ackAgent(ownerToken);
|
|
186
|
+
const { scheduleId, taskId } = await fireSchedule(ownerToken, "creatorless");
|
|
187
|
+
db.run("UPDATE scheduled_tasks SET created_by = NULL WHERE schedule_id = ?1", [scheduleId]);
|
|
188
|
+
expect((await nodeReply({ in_reply_to: taskId, text: "nobody's", status: "replied" })).ok).toBe(true);
|
|
189
|
+
expect(replyRow(taskId)?.session_name).toBe("scheduler");
|
|
190
|
+
expect((await unreadFor(ownerToken))[ALIAS]).toBeUndefined();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("a creator whose username is also a node alias falls back to 'scheduler' (the collision guard)", async () => {
|
|
194
|
+
await ackAgent(ownerToken);
|
|
195
|
+
const { taskId } = await fireSchedule(ownerToken, "colliding-creator");
|
|
196
|
+
const clashNodeId = `n_clash_${stamp}`;
|
|
197
|
+
db.run(
|
|
198
|
+
"INSERT INTO nodes (node_id, node_name, alias, runtime, network_id) VALUES (?1, ?2, ?2, 'claude-code', ?3)",
|
|
199
|
+
[clashNodeId, ownerName, networkId],
|
|
200
|
+
);
|
|
201
|
+
try {
|
|
202
|
+
expect((await nodeReply({ in_reply_to: taskId, text: "not a todo", status: "replied" })).ok).toBe(true);
|
|
203
|
+
// 那个同名节点收不到这条(它不是节点的待办),用户名也拿不到(inbox 半边整体跳过)
|
|
204
|
+
expect(replyRow(taskId)?.session_name).toBe("scheduler");
|
|
205
|
+
expect(db.get<{ n: number }>(
|
|
206
|
+
"SELECT COUNT(*) AS n FROM inbox WHERE session_name = ?1 AND in_reply_to = ?2", ownerName, taskId,
|
|
207
|
+
)?.n).toBe(0);
|
|
208
|
+
} finally {
|
|
209
|
+
db.run("DELETE FROM nodes WHERE node_id = ?1", [clashNodeId]);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { db, chainReplyToParent } from "./db.js";
|
|
4
|
+
import { dispatchScheduledOccurrence } from "./scheduled-tasks.js";
|
|
5
|
+
import { registerTools } from "./tools.js";
|
|
6
|
+
import { patrolExpiredTasks } from "./server.js";
|
|
7
|
+
|
|
8
|
+
const NET = "net_690_scheduler_terminal";
|
|
9
|
+
const USER = "u_690_owner";
|
|
10
|
+
const NODE = "n_690_grok";
|
|
11
|
+
const ALIAS = "A站GrokTUI-690";
|
|
12
|
+
|
|
13
|
+
type Handler = (args: any, extra?: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
|
|
14
|
+
|
|
15
|
+
function cleanup(): void {
|
|
16
|
+
for (const table of ["task_events", "inbox", "scheduled_task_runs", "scheduled_tasks", "tasks", "sessions", "nodes", "network_members", "networks", "users"]) {
|
|
17
|
+
try { db.run(`DELETE FROM ${table} WHERE network_id = ?1`, [NET]); } catch {}
|
|
18
|
+
}
|
|
19
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function seedIdentity(): void {
|
|
23
|
+
db.run("INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))", [USER, USER]);
|
|
24
|
+
db.run("INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))", [NET, USER]);
|
|
25
|
+
db.run("INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))", [USER, NET]);
|
|
26
|
+
db.run(
|
|
27
|
+
`INSERT INTO nodes (node_id, node_name, alias, runtime, network_id, lifecycle_state)
|
|
28
|
+
VALUES (?1, ?2, ?2, 'grok-build-acp', ?3, 'active')`,
|
|
29
|
+
[NODE, ALIAS, NET],
|
|
30
|
+
);
|
|
31
|
+
db.run(
|
|
32
|
+
`INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
|
|
33
|
+
VALUES ('r_690', ?1, 'idle', ?2, ?3, datetime('now'), datetime('now'))`,
|
|
34
|
+
[ALIAS, NODE, NET],
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function handlers(): Record<string, Handler> {
|
|
39
|
+
const server = new McpServer({ name: "test690", version: "0" }) as any;
|
|
40
|
+
const result: Record<string, Handler> = {};
|
|
41
|
+
const original = server.tool.bind(server);
|
|
42
|
+
server.tool = (name: string, ...args: any[]) => {
|
|
43
|
+
const handler = args.at(-1);
|
|
44
|
+
if (typeof handler === "function") result[name] = handler;
|
|
45
|
+
return original(name, ...args);
|
|
46
|
+
};
|
|
47
|
+
registerTools(server, undefined, NET, USER, USER, false, null);
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function call(handler: Handler, args: any): Promise<any> {
|
|
52
|
+
const reply = await handler(args);
|
|
53
|
+
return JSON.parse(reply.content[0].text);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function seedScheduledTask(status = "delivered", suffix = crypto.randomUUID()): { taskId: string; runId: string; scheduleId: string } {
|
|
57
|
+
const taskId = `task_${suffix}`;
|
|
58
|
+
const runId = `srun_${suffix}`;
|
|
59
|
+
const scheduleId = `sched_${suffix}`;
|
|
60
|
+
const meta = JSON.stringify({ scheduled_task_id: scheduleId, scheduled_run_id: runId, auth_origin: "hub_scheduler" });
|
|
61
|
+
db.run(
|
|
62
|
+
`INSERT INTO scheduled_tasks
|
|
63
|
+
(schedule_id, network_id, name, target_node_id, target_alias, task_content, priority,
|
|
64
|
+
schedule_type, schedule_json, timezone, status, next_run_at)
|
|
65
|
+
VALUES (?1, ?2, 'test690', ?3, ?4, 'test690 task', 'normal', 'interval',
|
|
66
|
+
'{"type":"interval","every_seconds":3600}', 'UTC', 'active', datetime('now', '+1 hour'))`,
|
|
67
|
+
[scheduleId, NET, NODE, ALIAS],
|
|
68
|
+
);
|
|
69
|
+
db.run(
|
|
70
|
+
`INSERT INTO tasks
|
|
71
|
+
(task_id, from_name, to_node_id, to_name, priority, status, content, requires_response,
|
|
72
|
+
created_at, delivered_at, expires_at, network_id, meta_json)
|
|
73
|
+
VALUES (?1, 'scheduler', ?2, ?3, 'normal', ?4, 'test690 task', 'reply',
|
|
74
|
+
datetime('now'), datetime('now'), datetime('now', '+1 hour'), ?5, ?6)`,
|
|
75
|
+
[taskId, NODE, ALIAS, status, NET, meta],
|
|
76
|
+
);
|
|
77
|
+
db.run(
|
|
78
|
+
`INSERT INTO inbox (id, task_id, session_name, node_id, type, priority, content, from_session, requires_response, network_id, meta_json)
|
|
79
|
+
VALUES (?1, ?1, ?2, ?3, 'task', 'normal', 'test690 task', 'scheduler', 'reply', ?4, ?5)`,
|
|
80
|
+
[taskId, ALIAS, NODE, NET, meta],
|
|
81
|
+
);
|
|
82
|
+
db.run(
|
|
83
|
+
`INSERT INTO scheduled_task_runs (run_id, schedule_id, network_id, scheduled_for, task_id, status, completed_at)
|
|
84
|
+
VALUES (?1, ?2, ?3, datetime('now'), ?4, ?5, datetime('now'))`,
|
|
85
|
+
[runId, scheduleId, NET, taskId, status],
|
|
86
|
+
);
|
|
87
|
+
return { taskId, runId, scheduleId };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const readRun = (runId: string) => db.get<any>(
|
|
91
|
+
"SELECT run_id, task_id, network_id, status, error_code, completed_at FROM scheduled_task_runs WHERE run_id = ?1",
|
|
92
|
+
runId,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
beforeEach(() => { cleanup(); seedIdentity(); });
|
|
96
|
+
afterAll(cleanup);
|
|
97
|
+
|
|
98
|
+
describe("#690 scheduled run follows the real task lifecycle", () => {
|
|
99
|
+
test("dispatch is open until the task reaches a terminal state", () => {
|
|
100
|
+
const scheduleId = `sched_dispatch_${crypto.randomUUID()}`;
|
|
101
|
+
const row: any = {
|
|
102
|
+
schedule_id: scheduleId,
|
|
103
|
+
network_id: NET,
|
|
104
|
+
name: "dispatch open",
|
|
105
|
+
target_node_id: NODE,
|
|
106
|
+
target_alias: ALIAS,
|
|
107
|
+
task_content: "dispatch open",
|
|
108
|
+
priority: "normal",
|
|
109
|
+
schedule_type: "interval",
|
|
110
|
+
schedule_json: JSON.stringify({ type: "interval", every_seconds: 3600 }),
|
|
111
|
+
timezone: "UTC",
|
|
112
|
+
overlap_policy: "skip",
|
|
113
|
+
misfire_policy: "catch_up_once",
|
|
114
|
+
status: "active",
|
|
115
|
+
next_run_at: new Date(Date.now() + 3600_000).toISOString(),
|
|
116
|
+
last_run_at: null,
|
|
117
|
+
revision: 1,
|
|
118
|
+
};
|
|
119
|
+
db.run(
|
|
120
|
+
`INSERT INTO scheduled_tasks
|
|
121
|
+
(schedule_id, network_id, name, target_node_id, target_alias, task_content, priority,
|
|
122
|
+
schedule_type, schedule_json, timezone, overlap_policy, misfire_policy, status, next_run_at)
|
|
123
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)`,
|
|
124
|
+
[scheduleId, NET, row.name, NODE, ALIAS, row.task_content, row.priority, row.schedule_type,
|
|
125
|
+
row.schedule_json, row.timezone, row.overlap_policy, row.misfire_policy, row.status, row.next_run_at],
|
|
126
|
+
);
|
|
127
|
+
const dispatched = dispatchScheduledOccurrence(row, new Date().toISOString(), false);
|
|
128
|
+
const run = readRun(dispatched.runId);
|
|
129
|
+
expect(["delivered", "queued"]).toContain(run.status);
|
|
130
|
+
expect(run.completed_at).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("send_reply terminalizes the exact bound run", async () => {
|
|
134
|
+
const { taskId, runId } = seedScheduledTask();
|
|
135
|
+
const reply = await call(handlers().send_reply, {
|
|
136
|
+
alias: ALIAS,
|
|
137
|
+
text: "verified",
|
|
138
|
+
in_reply_to: taskId,
|
|
139
|
+
status: "replied",
|
|
140
|
+
network_id: NET,
|
|
141
|
+
attachments: [{ type: "file", file_id: "file_690_reply", name: "evidence.json", size: 12, mime: "application/json" }],
|
|
142
|
+
});
|
|
143
|
+
expect(reply.ok).toBe(true);
|
|
144
|
+
expect(readRun(runId).status).toBe("replied");
|
|
145
|
+
expect(readRun(runId).completed_at).toBeTruthy();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("task and run terminal transition roll back together on an injected run-write failure", async () => {
|
|
149
|
+
const { taskId, runId } = seedScheduledTask();
|
|
150
|
+
db.exec(
|
|
151
|
+
`CREATE TRIGGER test690_fail_run_terminal
|
|
152
|
+
BEFORE UPDATE OF status ON scheduled_task_runs
|
|
153
|
+
WHEN NEW.run_id = '${runId}' AND NEW.status = 'replied'
|
|
154
|
+
BEGIN
|
|
155
|
+
SELECT RAISE(ABORT, 'test690_injected_run_failure');
|
|
156
|
+
END`,
|
|
157
|
+
);
|
|
158
|
+
let threw = false;
|
|
159
|
+
try {
|
|
160
|
+
await call(handlers().send_reply, {
|
|
161
|
+
alias: ALIAS,
|
|
162
|
+
text: "must roll back",
|
|
163
|
+
in_reply_to: taskId,
|
|
164
|
+
status: "replied",
|
|
165
|
+
network_id: NET,
|
|
166
|
+
});
|
|
167
|
+
} catch (error: any) {
|
|
168
|
+
threw = /test690_injected_run_failure/.test(String(error?.message || error));
|
|
169
|
+
} finally {
|
|
170
|
+
db.exec("DROP TRIGGER IF EXISTS test690_fail_run_terminal");
|
|
171
|
+
}
|
|
172
|
+
expect(threw).toBe(true);
|
|
173
|
+
expect(db.get<{ status: string }>("SELECT status FROM tasks WHERE task_id = ?1", taskId)?.status).toBe("delivered");
|
|
174
|
+
expect(readRun(runId).status).toBe("delivered");
|
|
175
|
+
expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM inbox WHERE in_reply_to = ?1", taskId)?.count).toBe(0);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("cancel terminalizes, retry reopens, and the retry result closes the same run", async () => {
|
|
179
|
+
const { taskId, runId } = seedScheduledTask();
|
|
180
|
+
const tools = handlers();
|
|
181
|
+
expect((await call(tools.cancel_task, { task_id: taskId, reason: "operator", network_id: NET })).ok).toBe(true);
|
|
182
|
+
expect(readRun(runId).status).toBe("cancelled");
|
|
183
|
+
expect(readRun(runId).completed_at).toBeTruthy();
|
|
184
|
+
|
|
185
|
+
expect((await call(tools.retry_task, { task_id: taskId, network_id: NET })).ok).toBe(true);
|
|
186
|
+
expect(readRun(runId).status).toBe("delivered");
|
|
187
|
+
expect(readRun(runId).completed_at).toBeNull();
|
|
188
|
+
|
|
189
|
+
expect((await call(tools.send_reply, {
|
|
190
|
+
alias: ALIAS,
|
|
191
|
+
text: "runtime failed",
|
|
192
|
+
in_reply_to: taskId,
|
|
193
|
+
status: "failed",
|
|
194
|
+
network_id: NET,
|
|
195
|
+
})).ok).toBe(true);
|
|
196
|
+
expect(readRun(runId).status).toBe("failed");
|
|
197
|
+
expect(readRun(runId).error_code).toBe("task_failed");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("report_completion terminalizes a scheduler run", async () => {
|
|
201
|
+
const { taskId, runId } = seedScheduledTask();
|
|
202
|
+
const reply = await call(handlers().report_completion, {
|
|
203
|
+
alias: ALIAS,
|
|
204
|
+
task: taskId,
|
|
205
|
+
result: "done",
|
|
206
|
+
network_id: NET,
|
|
207
|
+
});
|
|
208
|
+
expect(reply.ok).toBe(true);
|
|
209
|
+
expect(readRun(runId).status).toBe("replied");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("expiration patrol closes the exact run in the same lifecycle pass", () => {
|
|
213
|
+
const { taskId, runId } = seedScheduledTask();
|
|
214
|
+
db.run("UPDATE tasks SET expires_at = datetime('now', '-1 minute') WHERE task_id = ?1", [taskId]);
|
|
215
|
+
patrolExpiredTasks();
|
|
216
|
+
expect(db.get<{ status: string }>("SELECT status FROM tasks WHERE task_id = ?1", taskId)?.status).toBe("expired");
|
|
217
|
+
expect(readRun(runId).status).toBe("expired");
|
|
218
|
+
expect(readRun(runId).error_code).toBe("task_expired");
|
|
219
|
+
expect(readRun(runId).completed_at).toBeTruthy();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("child auto-chain terminalizes a scheduled parent", () => {
|
|
223
|
+
const { taskId: parentId, runId } = seedScheduledTask();
|
|
224
|
+
const childId = `child_${crypto.randomUUID()}`;
|
|
225
|
+
db.run(
|
|
226
|
+
`INSERT INTO tasks
|
|
227
|
+
(task_id, parent_task_id, from_name, to_name, priority, status, content, requires_response, created_at, network_id)
|
|
228
|
+
VALUES (?1, ?2, ?3, 'A站负责人', 'normal', 'replied', 'verify candidate', 'reply', datetime('now'), ?4)`,
|
|
229
|
+
[childId, parentId, ALIAS, NET],
|
|
230
|
+
);
|
|
231
|
+
const result = chainReplyToParent(childId, "news_id=14519", "replied", 5, NET);
|
|
232
|
+
expect(result.chained).toBe(true);
|
|
233
|
+
expect(readRun(runId).status).toBe("replied");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("forged metadata cannot cross-bind another task or network run", async () => {
|
|
237
|
+
const victim = seedScheduledTask("delivered", "victim");
|
|
238
|
+
const attackerTaskId = "task_attacker";
|
|
239
|
+
const forgedMeta = JSON.stringify({ scheduled_task_id: victim.scheduleId, scheduled_run_id: victim.runId, auth_origin: "hub_scheduler" });
|
|
240
|
+
db.run(
|
|
241
|
+
`INSERT INTO tasks
|
|
242
|
+
(task_id, from_name, to_node_id, to_name, priority, status, content, requires_response, created_at, network_id, meta_json)
|
|
243
|
+
VALUES (?1, 'scheduler', ?2, ?3, 'normal', 'delivered', 'forged', 'reply', datetime('now'), ?4, ?5)`,
|
|
244
|
+
[attackerTaskId, NODE, ALIAS, NET, forgedMeta],
|
|
245
|
+
);
|
|
246
|
+
await call(handlers().send_reply, {
|
|
247
|
+
alias: ALIAS,
|
|
248
|
+
text: "forged close",
|
|
249
|
+
in_reply_to: attackerTaskId,
|
|
250
|
+
status: "replied",
|
|
251
|
+
network_id: NET,
|
|
252
|
+
});
|
|
253
|
+
expect(readRun(victim.runId).status).toBe("delivered");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("a task cannot close a run whose persisted network binding differs", async () => {
|
|
257
|
+
const victim = seedScheduledTask("delivered", "foreign_network");
|
|
258
|
+
db.run("UPDATE scheduled_task_runs SET network_id = 'net_690_foreign' WHERE run_id = ?1", [victim.runId]);
|
|
259
|
+
await call(handlers().send_reply, {
|
|
260
|
+
alias: ALIAS,
|
|
261
|
+
text: "must not cross network",
|
|
262
|
+
in_reply_to: victim.taskId,
|
|
263
|
+
status: "replied",
|
|
264
|
+
network_id: NET,
|
|
265
|
+
});
|
|
266
|
+
expect(readRun(victim.runId).status).toBe("delivered");
|
|
267
|
+
});
|
|
268
|
+
});
|