@sleep2agi/commhub-server 0.9.0-preview.13 → 0.9.0-preview.131
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 +99 -11
- package/package.json +7 -7
- package/src/ack-create-request-transport.test.ts +182 -0
- package/src/ack-create-request.test.ts +50 -7
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/adoption-candidates-http.test.ts +137 -0
- package/src/adoption-candidates.ts +158 -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 +659 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +223 -0
- package/src/agent-notice.ts +48 -0
- 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/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-host-supervisors-runtime-readiness.test.ts +195 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth-weak-password-login.test.ts +176 -0
- package/src/auth.ts +598 -49
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/binding-generation-http.test.ts +121 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/clone-node.test.ts +248 -0
- package/src/clone-node.ts +289 -0
- package/src/codex-config-flags.ts +77 -0
- package/src/codex-fork-contract.ts +23 -0
- package/src/codex-fork-migration.test.ts +38 -0
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +134 -1
- package/src/config-apply-validate.ts +111 -5
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-network-id.test.ts +79 -0
- package/src/create-node-tool-schema.test.ts +48 -0
- package/src/create-node-unicode-name.test.ts +120 -0
- package/src/create-node-validate.test.ts +193 -7
- package/src/create-node-validate.ts +227 -15
- package/src/create-node-workdir.test.ts +289 -0
- package/src/create-node.ts +77 -23
- package/src/daemon-provider-snapshots.test.ts +421 -0
- package/src/daemon-provider-snapshots.ts +327 -0
- package/src/daemon-token-owner-http.test.ts +236 -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 +933 -36
- 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 +330 -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-provider-config.test.ts +139 -0
- package/src/hub-provider-config.ts +105 -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-caller.ts +49 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2362
- package/src/lifecycle-guard.ts +1 -1
- package/src/mcp-context-cost-http.test.ts +123 -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 +432 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +210 -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 +276 -0
- package/src/network-token-membership-http.test.ts +139 -0
- package/src/node-alias-control.ts +12 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-caller-identity-http.test.ts +781 -0
- package/src/node-create-request-status-http.test.ts +105 -0
- package/src/node-daemon-bindings.test.ts +132 -0
- package/src/node-daemon-bindings.ts +130 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-health-codex-login.test.ts +120 -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 +101 -0
- package/src/node-id-alias.test.ts +190 -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 +53 -0
- package/src/node-lifecycle-read-http.test.ts +245 -0
- package/src/node-lifecycle-read.ts +121 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-permissions-http.test.ts +276 -0
- package/src/node-permissions.ts +285 -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-ownership.ts +51 -0
- package/src/node-token-privilege-http.test.ts +158 -0
- package/src/observer-avatar-http.test.ts +431 -0
- package/src/observer-push.test.ts +252 -0
- package/src/opencode-v2-catalog.test.ts +268 -0
- package/src/opencode-v2-catalog.ts +352 -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/project-dir-warn-clear-http.test.ts +132 -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 +22 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-handover-capabilities-http.test.ts +124 -0
- package/src/report-status-host-schema.test.ts +235 -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 +620 -0
- package/src/requirement-due-reminders.ts +416 -0
- package/src/requirement-events-http.test.ts +268 -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-abandoned-http.test.ts +236 -0
- package/src/requirements-comment-http.test.ts +167 -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 +349 -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 +259 -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 +167 -0
- package/src/requirements-status-participants-http.test.ts +222 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1806 -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 +130 -0
- package/src/rest-identity.ts +139 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +76 -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/runtime-readiness.ts +90 -0
- package/src/schedule-agent-mcp-http.test.ts +381 -0
- package/src/schedule-agent.ts +192 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
- package/src/scheduled-failures-http.test.ts +233 -0
- package/src/scheduled-failures.ts +179 -0
- package/src/scheduled-offline.test.ts +131 -0
- package/src/scheduled-offline.ts +66 -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 +766 -0
- package/src/scoped-secrets.ts +124 -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 +5257 -0
- package/src/session-task-on-dispatch.ts +14 -0
- package/src/session-token-ownership-http.test.ts +122 -0
- package/src/shared/codex-inventory.ts +41 -0
- package/src/shared/daemon-provider-login.ts +31 -0
- package/src/shared/daemon-provider-snapshot.ts +52 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/node-name.test.ts +62 -0
- package/src/shared/node-name.ts +161 -0
- package/src/shared/provider-application.ts +29 -0
- package/src/shared/provider-collection.ts +14 -0
- package/src/shared/provider-initialization.ts +8 -0
- package/src/shared/reserved-env-drift.test.ts +10 -0
- package/src/shared/scoped-secret-contract.ts +5 -0
- package/src/shared/task-content-limit-drift.test.ts +28 -0
- package/src/shared/task-content-limit.ts +25 -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 +264 -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 +509 -1
- package/src/task-access-http.test.ts +402 -0
- package/src/task-access.ts +420 -0
- package/src/task-auth-origin-http.test.ts +93 -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 +315 -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-late-replies.ts +54 -0
- package/src/task-late-reply-http.test.ts +152 -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 +140 -0
- package/src/task-orphan-http.test.ts +287 -0
- package/src/task-orphan.ts +119 -0
- package/src/task-queue-ahead-http.test.ts +309 -0
- package/src/task-queue-ahead-scale.test.ts +236 -0
- package/src/task-queue-ahead.ts +186 -0
- package/src/task-rejection-log-http.test.ts +160 -0
- package/src/task-rejection-log.ts +49 -0
- package/src/task-sender-http.test.ts +143 -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 +220 -0
- package/src/tool-audience.ts +118 -0
- package/src/tools-list-json-schema.test.ts +67 -0
- package/src/tools.ts +3940 -325
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.ts +103 -6
- package/src/user-avatar-http.test.ts +74 -0
- package/src/user-avatar-migration.test.ts +25 -0
- 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
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { db } from "./db.js";
|
|
3
|
+
import { createSSEStream } from "./push.js";
|
|
4
|
+
import { dispatchScheduledOccurrence } from "./scheduled-tasks.js";
|
|
5
|
+
|
|
6
|
+
// A claude-code node's channel server reports status without node_id, so its
|
|
7
|
+
// sessions row has node_id NULL. The scheduler used to look the session up by
|
|
8
|
+
// node_id only, judged every occurrence "queued" and never rang the doorbell:
|
|
9
|
+
// the task sat in the inbox until an unrelated push drained it (20–36 min in
|
|
10
|
+
// production).
|
|
11
|
+
|
|
12
|
+
const NET = "net_sched_doorbell_nodeless";
|
|
13
|
+
const NODE = "n_sched_doorbell_cc";
|
|
14
|
+
const ALIAS = "sched-doorbell-cc";
|
|
15
|
+
|
|
16
|
+
function cleanup(): void {
|
|
17
|
+
for (const table of ["task_events", "inbox", "scheduled_task_runs", "scheduled_tasks", "tasks", "sessions", "nodes"]) {
|
|
18
|
+
try { db.run(`DELETE FROM ${table} WHERE network_id = ?1`, [NET]); } catch {}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function seedNode(): void {
|
|
23
|
+
db.run(
|
|
24
|
+
`INSERT INTO nodes (node_id, node_name, alias, runtime, network_id, lifecycle_state)
|
|
25
|
+
VALUES (?1, ?2, ?2, 'claude-code-cli', ?3, 'active')`,
|
|
26
|
+
[NODE, ALIAS, NET],
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function seedSession(status: string, nodeId: string | null): void {
|
|
31
|
+
db.run(
|
|
32
|
+
`INSERT INTO sessions (resume_id, alias, status, node_id, network_id, agent, updated_at, last_seen_at)
|
|
33
|
+
VALUES (?1, ?2, ?3, ?4, ?5, 'claude-code', datetime('now'), datetime('now'))`,
|
|
34
|
+
[`cc-${crypto.randomUUID()}`, ALIAS, status, nodeId, NET],
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function seedSchedule(): any {
|
|
39
|
+
const scheduleId = `sched_${crypto.randomUUID()}`;
|
|
40
|
+
db.run(
|
|
41
|
+
`INSERT INTO scheduled_tasks
|
|
42
|
+
(schedule_id, network_id, name, target_node_id, target_alias, task_content, priority,
|
|
43
|
+
schedule_type, schedule_json, timezone, status, next_run_at)
|
|
44
|
+
VALUES (?1, ?2, 'doorbell', ?3, ?4, 'scheduled doorbell task', 'normal', 'interval',
|
|
45
|
+
'{"type":"interval","every_seconds":3600}', 'UTC', 'active', datetime('now', '+1 hour'))`,
|
|
46
|
+
[scheduleId, NET, NODE, ALIAS],
|
|
47
|
+
);
|
|
48
|
+
return db.get("SELECT * FROM scheduled_tasks WHERE schedule_id = ?1", scheduleId);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Subscribe like the channel server does and collect decoded SSE frames. */
|
|
52
|
+
function subscribe(): { frames: any[]; close: () => Promise<void> } {
|
|
53
|
+
const res = createSSEStream(ALIAS, NET);
|
|
54
|
+
const reader = res.body!.getReader();
|
|
55
|
+
const decoder = new TextDecoder();
|
|
56
|
+
const frames: any[] = [];
|
|
57
|
+
let buffer = "";
|
|
58
|
+
let open = true;
|
|
59
|
+
(async () => {
|
|
60
|
+
while (open) {
|
|
61
|
+
const { done, value } = await reader.read();
|
|
62
|
+
if (done) break;
|
|
63
|
+
buffer += decoder.decode(value, { stream: true });
|
|
64
|
+
const blocks = buffer.split("\n\n");
|
|
65
|
+
buffer = blocks.pop() || "";
|
|
66
|
+
for (const block of blocks) {
|
|
67
|
+
const line = block.split("\n").find((l) => l.startsWith("data: "));
|
|
68
|
+
if (line) frames.push(JSON.parse(line.slice(6)));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
})().catch(() => {});
|
|
72
|
+
return {
|
|
73
|
+
frames,
|
|
74
|
+
close: async () => {
|
|
75
|
+
open = false;
|
|
76
|
+
await reader.cancel().catch(() => {});
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function settle(): Promise<void> {
|
|
82
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
beforeEach(() => {
|
|
86
|
+
cleanup();
|
|
87
|
+
seedNode();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
afterAll(cleanup);
|
|
91
|
+
|
|
92
|
+
describe("scheduler doorbell for sessions reported without node_id", () => {
|
|
93
|
+
test("idle node_id-less session is delivered and its subscriber receives new_task", async () => {
|
|
94
|
+
seedSession("idle", null);
|
|
95
|
+
const sub = subscribe();
|
|
96
|
+
try {
|
|
97
|
+
await settle();
|
|
98
|
+
const result = dispatchScheduledOccurrence(seedSchedule(), new Date().toISOString(), false);
|
|
99
|
+
await settle();
|
|
100
|
+
expect(result.status).toBe("delivered");
|
|
101
|
+
expect(db.get<{ status: string }>("SELECT status FROM scheduled_task_runs WHERE run_id = ?1", result.runId)?.status).toBe("delivered");
|
|
102
|
+
const wake = sub.frames.filter((f) => f.type === "new_task");
|
|
103
|
+
expect(wake).toHaveLength(1);
|
|
104
|
+
expect(wake[0].from).toBe("scheduler");
|
|
105
|
+
expect(wake[0].inbox_count).toBe(1);
|
|
106
|
+
} finally {
|
|
107
|
+
await sub.close();
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("an offline session is skipped even if a stale subscriber is still connected", async () => {
|
|
112
|
+
seedSession("offline", null);
|
|
113
|
+
const sub = subscribe();
|
|
114
|
+
try {
|
|
115
|
+
await settle();
|
|
116
|
+
const result = dispatchScheduledOccurrence(seedSchedule(), new Date().toISOString(), false);
|
|
117
|
+
await settle();
|
|
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);
|
|
122
|
+
} finally {
|
|
123
|
+
await sub.close();
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("offline session with nobody listening is skipped without a task", () => {
|
|
128
|
+
seedSession("offline", null);
|
|
129
|
+
const result = dispatchScheduledOccurrence(seedSchedule(), new Date().toISOString(), false);
|
|
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 });
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// #523 —— 定时任务连续失败:告诉创建者(可选自动暂停)。
|
|
2
|
+
//
|
|
3
|
+
// 钉住:
|
|
4
|
+
// 1. 连续 5 次失败(派出去的任务以 failed 结束)→ 给创建者恰好 1 条通知(别的成员 0 条),第 6–10 次不再发;
|
|
5
|
+
// 通知里有排程名、目标节点、失败次数、最近一次的错误(截断)和怎么暂停。
|
|
6
|
+
// 2. 派发时就失败(目标节点不存在)同样计数。
|
|
7
|
+
// 3. 中间成功一次 → 重新上膛:再连续 5 次失败才发第二条。
|
|
8
|
+
// 4. 一直失败:24 小时内不重发;上次告警早于 24 小时 → 再发一条。
|
|
9
|
+
// 5. 默认不自动暂停;COMMHUB_SCHEDULE_FAILURE_AUTO_PAUSE_RUNS=3 → 第 3 次失败后排程 paused、next_run_at 清空、
|
|
10
|
+
// revision+1,并通知一次。
|
|
11
|
+
// 6. GET /runs 返回 consecutive_failures / failure_alert_threshold / last_failure_alert_at,
|
|
12
|
+
// 任务以 failed 结束的 run 的 error_message 取自任务的回复正文。
|
|
13
|
+
//
|
|
14
|
+
// 跑法:cd server && COMMHUB_DB=/tmp/x.db bun test src/scheduled-failures-http.test.ts
|
|
15
|
+
// PG:COMMHUB_TEST_PG_URL=… COMMHUB_PG_EXPERIMENTAL=1(tests/test2123-hub-postgres-ladder 里注册)
|
|
16
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
17
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
18
|
+
import { addNetworkMember, createNetworkTokenForNode, register } from "./auth.js";
|
|
19
|
+
import { db } from "./db.js";
|
|
20
|
+
import { dispatchScheduledOccurrence, handleScheduledTaskRequest } from "./scheduled-tasks.js";
|
|
21
|
+
import { SCHEDULE_FAILING_NOTICE_KIND, failureStreak } from "./scheduled-failures.js";
|
|
22
|
+
import { registerTools } from "./tools.js";
|
|
23
|
+
|
|
24
|
+
const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
|
|
25
|
+
if (!activeDbPath) throw new Error("scheduled-failures requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
|
|
26
|
+
|
|
27
|
+
const stamp = Date.now();
|
|
28
|
+
const ALIAS = `fail-node-${stamp}`;
|
|
29
|
+
const NODE_ID = `n_fail_${stamp}`;
|
|
30
|
+
let NET = "", creatorId = "", otherId = "";
|
|
31
|
+
const ENV_KEYS = ["COMMHUB_SCHEDULE_FAILURE_NOTICE_RUNS", "COMMHUB_SCHEDULE_FAILURE_RENOTICE_SEC", "COMMHUB_SCHEDULE_FAILURE_AUTO_PAUSE_RUNS"];
|
|
32
|
+
const SAFETY_ERROR = "执行出错: upstream model refused the request (safety system). " + "x".repeat(600);
|
|
33
|
+
|
|
34
|
+
function seedSchedule(name = "前后端分离巡检", targetNodeId = NODE_ID): any {
|
|
35
|
+
const scheduleId = `sched_${crypto.randomUUID()}`;
|
|
36
|
+
db.run(
|
|
37
|
+
`INSERT INTO scheduled_tasks
|
|
38
|
+
(schedule_id, network_id, created_by, name, target_node_id, target_alias, task_content, priority,
|
|
39
|
+
schedule_type, schedule_json, timezone, status, next_run_at)
|
|
40
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'scheduled task', 'normal', 'interval',
|
|
41
|
+
'{"type":"interval","every_seconds":120}', 'Asia/Shanghai', 'active', datetime('now', '+2 minutes'))`,
|
|
42
|
+
[scheduleId, NET, creatorId, name, targetNodeId, ALIAS],
|
|
43
|
+
);
|
|
44
|
+
return reload(scheduleId);
|
|
45
|
+
}
|
|
46
|
+
const reload = (scheduleId: string) => db.get<any>("SELECT * FROM scheduled_tasks WHERE schedule_id = ?1", scheduleId);
|
|
47
|
+
|
|
48
|
+
let slot = 0;
|
|
49
|
+
const nextSlot = () => new Date(Date.UTC(2026, 9, 4, 0, 0, 0) + (++slot) * 120_000).toISOString();
|
|
50
|
+
const fire = (schedule: any, now = new Date()) => dispatchScheduledOccurrence(schedule, nextSlot(), false, now);
|
|
51
|
+
|
|
52
|
+
const notices = (userId: string) => db.all<{ title: string; content: string; meta_json: string; from_session: string; severity: string }>(
|
|
53
|
+
"SELECT title, content, meta_json, from_session, severity FROM user_inbox WHERE user_id = ?1 AND network_id = ?2 AND kind = ?3 ORDER BY created_at, message_id",
|
|
54
|
+
userId, NET, SCHEDULE_FAILING_NOTICE_KIND,
|
|
55
|
+
);
|
|
56
|
+
const noticesFor = (userId: string, scheduleId: string) => notices(userId).filter((n) => JSON.parse(n.meta_json).schedule_failing.schedule_id === scheduleId);
|
|
57
|
+
|
|
58
|
+
async function nodeReply(args: Record<string, unknown>): Promise<any> {
|
|
59
|
+
const mcp = new McpServer({ name: "sched-fail", version: "0" }) as any;
|
|
60
|
+
const handlers: Record<string, (a: any) => Promise<any>> = {};
|
|
61
|
+
const original = mcp.tool.bind(mcp);
|
|
62
|
+
mcp.tool = (name: string, ...rest: any[]) => {
|
|
63
|
+
const handler = rest.at(-1);
|
|
64
|
+
if (typeof handler === "function") handlers[name] = handler;
|
|
65
|
+
return original(name, ...rest);
|
|
66
|
+
};
|
|
67
|
+
registerTools(mcp, undefined, NET, creatorId, ALIAS, true, null);
|
|
68
|
+
const out = await handlers.send_reply(args);
|
|
69
|
+
return JSON.parse(out.content[0].text);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 派一次,节点以 failed(或 replied)回复。 */
|
|
73
|
+
async function runOnce(schedule: any, outcome: "failed" | "replied" = "failed"): Promise<{ runId: string; taskId: string }> {
|
|
74
|
+
const r = fire(schedule);
|
|
75
|
+
expect(r.status).toBe("delivered");
|
|
76
|
+
const out = await nodeReply({ alias: "scheduler", text: outcome === "failed" ? SAFETY_ERROR : "done", in_reply_to: r.taskId, status: outcome, from_session: ALIAS });
|
|
77
|
+
expect(out.ok).toBe(true);
|
|
78
|
+
return { runId: r.runId, taskId: r.taskId! };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function getRuns(scheduleId: string): Promise<any> {
|
|
82
|
+
const url = new URL(`http://hub.test/api/scheduled-tasks/${scheduleId}/runs`);
|
|
83
|
+
const res = await handleScheduledTaskRequest({
|
|
84
|
+
req: new Request(url, { method: "GET" }), url,
|
|
85
|
+
auth: { userId: creatorId, networkId: NET, username: "creator" },
|
|
86
|
+
isAdmin: false, isNodeToken: false, scope: { networkId: NET, networkIds: [NET] } as any,
|
|
87
|
+
});
|
|
88
|
+
expect(res!.status).toBe(200);
|
|
89
|
+
return res!.json();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
beforeAll(() => {
|
|
93
|
+
const creator = register(`fail_creator_${stamp}`, "FailCreator123!", undefined, "seed");
|
|
94
|
+
expect(creator.ok).toBe(true);
|
|
95
|
+
NET = creator.network_id!; creatorId = creator.user!.user_id;
|
|
96
|
+
const other = register(`fail_other_${stamp}`, "FailOther123!", undefined, "seed");
|
|
97
|
+
otherId = other.user!.user_id;
|
|
98
|
+
expect(addNetworkMember(NET, otherId, "member", creatorId, { agentAccess: "all" }).ok).toBe(true);
|
|
99
|
+
expect(createNetworkTokenForNode(creatorId, NET, ALIAS).ok).toBe(true);
|
|
100
|
+
db.run("INSERT INTO nodes (node_id, node_name, alias, runtime, network_id) VALUES (?1, ?2, ?2, 'claude-code', ?3)", [NODE_ID, ALIAS, NET]);
|
|
101
|
+
db.run("INSERT INTO sessions (resume_id, alias, status, node_id, network_id) VALUES (?1, ?2, 'idle', ?3, ?4)", [`r_fail_${stamp}`, ALIAS, NODE_ID, NET]);
|
|
102
|
+
});
|
|
103
|
+
beforeEach(() => { for (const k of ENV_KEYS) delete process.env[k]; });
|
|
104
|
+
afterEach(() => { for (const k of ENV_KEYS) delete process.env[k]; });
|
|
105
|
+
afterAll(() => { db.run("DELETE FROM user_inbox WHERE network_id = ?1 AND kind = ?2", [NET, SCHEDULE_FAILING_NOTICE_KIND]); });
|
|
106
|
+
|
|
107
|
+
describe("#523 consecutive-failure alert", () => {
|
|
108
|
+
test("5 failed runs → exactly one notice to the creator with name/target/count/error/how to pause; runs 6–10 stay quiet", async () => {
|
|
109
|
+
const s = seedSchedule();
|
|
110
|
+
for (let i = 0; i < 4; i++) await runOnce(s);
|
|
111
|
+
expect(noticesFor(creatorId, s.schedule_id).length).toBe(0);
|
|
112
|
+
await runOnce(s);
|
|
113
|
+
const mine = noticesFor(creatorId, s.schedule_id);
|
|
114
|
+
expect(mine.length).toBe(1);
|
|
115
|
+
expect(noticesFor(otherId, s.schedule_id).length).toBe(0);
|
|
116
|
+
const n = mine[0];
|
|
117
|
+
expect(n.from_session).toBe(ALIAS);
|
|
118
|
+
expect(n.title).toBe("定时任务连续失败");
|
|
119
|
+
expect(n.severity).toBe("error");
|
|
120
|
+
for (const frag of ["前后端分离巡检", ALIAS, "连续 5 次", "safety system", "task_failed", "暂停", s.schedule_id]) expect(n.content).toContain(frag);
|
|
121
|
+
expect(n.content).not.toContain("x".repeat(400)); // 错误被截断
|
|
122
|
+
expect(JSON.parse(n.meta_json).schedule_failing).toEqual({ reason: "failing", schedule_id: s.schedule_id, consecutive_failures: 5, error_code: "task_failed" });
|
|
123
|
+
for (let i = 0; i < 5; i++) await runOnce(s);
|
|
124
|
+
expect(noticesFor(creatorId, s.schedule_id).length).toBe(1);
|
|
125
|
+
expect(reload(s.schedule_id).status).toBe("active"); // 默认不自动暂停
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("dispatch-time failures (target node gone) count too", () => {
|
|
129
|
+
const s = seedSchedule("孤儿排程", `n_missing_${stamp}`);
|
|
130
|
+
for (let i = 0; i < 5; i++) expect(fire(s).status).toBe("failed");
|
|
131
|
+
const mine = noticesFor(creatorId, s.schedule_id);
|
|
132
|
+
expect(mine.length).toBe(1);
|
|
133
|
+
expect(mine[0].content).toContain("target_node_not_found");
|
|
134
|
+
expect(mine[0].content).toContain("The bound node no longer exists");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("a success in between re-arms: the next 5 failures notify again", async () => {
|
|
138
|
+
const s = seedSchedule();
|
|
139
|
+
for (let i = 0; i < 5; i++) await runOnce(s);
|
|
140
|
+
expect(noticesFor(creatorId, s.schedule_id).length).toBe(1);
|
|
141
|
+
await runOnce(s, "replied");
|
|
142
|
+
expect(failureStreak(s.schedule_id).consecutiveFailures).toBe(0);
|
|
143
|
+
for (let i = 0; i < 4; i++) await runOnce(s);
|
|
144
|
+
expect(noticesFor(creatorId, s.schedule_id).length).toBe(1);
|
|
145
|
+
await runOnce(s);
|
|
146
|
+
expect(noticesFor(creatorId, s.schedule_id).length).toBe(2);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("still failing: no repeat within 24h; one more once the last alert is older than 24h", async () => {
|
|
150
|
+
const s = seedSchedule();
|
|
151
|
+
for (let i = 0; i < 5; i++) await runOnce(s);
|
|
152
|
+
expect(noticesFor(creatorId, s.schedule_id).length).toBe(1);
|
|
153
|
+
db.run("UPDATE scheduled_tasks SET failure_alert_at = ?1 WHERE schedule_id = ?2", [new Date(Date.now() - 23 * 3_600_000).toISOString(), s.schedule_id]);
|
|
154
|
+
await runOnce(s);
|
|
155
|
+
expect(noticesFor(creatorId, s.schedule_id).length).toBe(1);
|
|
156
|
+
db.run("UPDATE scheduled_tasks SET failure_alert_at = ?1 WHERE schedule_id = ?2", [new Date(Date.now() - 25 * 3_600_000).toISOString(), s.schedule_id]);
|
|
157
|
+
await runOnce(s);
|
|
158
|
+
expect(noticesFor(creatorId, s.schedule_id).length).toBe(2);
|
|
159
|
+
await runOnce(s);
|
|
160
|
+
expect(noticesFor(creatorId, s.schedule_id).length).toBe(2);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("COMMHUB_SCHEDULE_FAILURE_NOTICE_RUNS changes N; no creator → no notice", async () => {
|
|
164
|
+
process.env.COMMHUB_SCHEDULE_FAILURE_NOTICE_RUNS = "2";
|
|
165
|
+
const s = seedSchedule();
|
|
166
|
+
await runOnce(s);
|
|
167
|
+
expect(noticesFor(creatorId, s.schedule_id).length).toBe(0);
|
|
168
|
+
await runOnce(s);
|
|
169
|
+
expect(noticesFor(creatorId, s.schedule_id).length).toBe(1);
|
|
170
|
+
|
|
171
|
+
const orphan = seedSchedule();
|
|
172
|
+
db.run("UPDATE scheduled_tasks SET created_by = NULL WHERE schedule_id = ?1", [orphan.schedule_id]);
|
|
173
|
+
const row = reload(orphan.schedule_id);
|
|
174
|
+
for (let i = 0; i < 3; i++) await runOnce(row);
|
|
175
|
+
expect(noticesFor(creatorId, orphan.schedule_id).length).toBe(0);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("#523 auto-pause (opt-in)", () => {
|
|
180
|
+
test("off by default: 10 failures leave the schedule active", async () => {
|
|
181
|
+
const s = seedSchedule();
|
|
182
|
+
for (let i = 0; i < 10; i++) await runOnce(s);
|
|
183
|
+
const after = reload(s.schedule_id);
|
|
184
|
+
expect(after.status).toBe("active");
|
|
185
|
+
expect(after.next_run_at).not.toBeNull();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("COMMHUB_SCHEDULE_FAILURE_AUTO_PAUSE_RUNS=3 pauses after the 3rd failure and tells the creator once", async () => {
|
|
189
|
+
process.env.COMMHUB_SCHEDULE_FAILURE_AUTO_PAUSE_RUNS = "3";
|
|
190
|
+
const s = seedSchedule();
|
|
191
|
+
await runOnce(s); await runOnce(s);
|
|
192
|
+
expect(reload(s.schedule_id).status).toBe("active");
|
|
193
|
+
await runOnce(s);
|
|
194
|
+
const after = reload(s.schedule_id);
|
|
195
|
+
expect(after.status).toBe("paused");
|
|
196
|
+
expect(after.next_run_at).toBeNull();
|
|
197
|
+
expect(after.revision).toBe(s.revision + 1);
|
|
198
|
+
const mine = noticesFor(creatorId, s.schedule_id);
|
|
199
|
+
expect(mine.length).toBe(1);
|
|
200
|
+
expect(mine[0].title).toBe("定时任务连续失败,已自动暂停");
|
|
201
|
+
expect(JSON.parse(mine[0].meta_json).schedule_failing.reason).toBe("paused");
|
|
202
|
+
// 手动 run-now 再失败两次到 N=5:同一段失败,已经通知过 → 不再发
|
|
203
|
+
await runOnce(after); await runOnce(after);
|
|
204
|
+
expect(noticesFor(creatorId, s.schedule_id).length).toBe(1);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("#523 GET /runs", () => {
|
|
209
|
+
test("exposes consecutive_failures and the failed task's reason as error_message", async () => {
|
|
210
|
+
const s = seedSchedule();
|
|
211
|
+
await runOnce(s, "replied");
|
|
212
|
+
const fails = [await runOnce(s), await runOnce(s)];
|
|
213
|
+
const body = await getRuns(s.schedule_id);
|
|
214
|
+
expect(body.ok).toBe(true);
|
|
215
|
+
expect(body.consecutive_failures).toBe(2);
|
|
216
|
+
expect(body.failure_alert_threshold).toBe(5);
|
|
217
|
+
expect(body.last_failure_alert_at).toBeNull();
|
|
218
|
+
expect(body.runs.length).toBe(3);
|
|
219
|
+
const failed = body.runs.find((r: any) => r.run_id === fails[1].runId);
|
|
220
|
+
expect(failed.status).toBe("failed");
|
|
221
|
+
expect(failed.error_code).toBe("task_failed");
|
|
222
|
+
expect(failed.error_message).toContain("safety system");
|
|
223
|
+
expect(failed.error_message.length).toBeLessThanOrEqual(500);
|
|
224
|
+
for (const k of ["scheduled_for", "created_at", "completed_at", "task_id"]) expect(k in failed).toBe(true);
|
|
225
|
+
const ok = body.runs.find((r: any) => r.status === "replied");
|
|
226
|
+
expect(ok.error_message).toBeNull();
|
|
227
|
+
|
|
228
|
+
for (let i = 0; i < 3; i++) await runOnce(s);
|
|
229
|
+
const again = await getRuns(s.schedule_id);
|
|
230
|
+
expect(again.consecutive_failures).toBe(5);
|
|
231
|
+
expect(typeof again.last_failure_alert_at).toBe("string");
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -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
|
+
});
|