@sleep2agi/commhub-server 0.9.0-preview.14 → 0.9.0-preview.140
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-department-http.test.ts +148 -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 +423 -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 +604 -51
- 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/chain-attempt-dedup.test.ts +447 -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 +49 -0
- package/src/create-node-unicode-name.test.ts +120 -0
- package/src/create-node-validate.test.ts +199 -7
- package/src/create-node-validate.ts +232 -15
- package/src/create-node-workdir.test.ts +289 -0
- package/src/create-node.ts +88 -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 +109 -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 +1111 -42
- 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 +276 -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/env-transport-gate-http.test.ts +146 -0
- package/src/env-transport-gate.ts +74 -0
- package/src/env-transport-policy.ts +21 -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/host-supervisor-identity-http.test.ts +152 -0
- package/src/host-supervisor-identity.ts +92 -0
- package/src/http-gzip.test.ts +94 -0
- package/src/http-gzip.ts +112 -0
- package/src/hub-catalog-read-http.test.ts +187 -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/human-inbox.ts +42 -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/list-host-supervisors.test.ts +5 -4
- 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 +1083 -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 +135 -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-secret-rest-http.test.ts +190 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-identity.ts +35 -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 +442 -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/password-network-tokens.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 +38 -10
- 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 +184 -0
- package/src/report-status-secret-keys.test.ts +140 -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 +131 -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 +357 -0
- package/src/requirements-stats.ts +195 -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 +141 -0
- package/src/rest-projections.ts +70 -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-backoff-http.test.ts +193 -0
- package/src/scheduled-backoff.ts +64 -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-outcome-guidance-http.test.ts +139 -0
- package/src/scheduled-outcome-guidance.ts +9 -0
- package/src/scheduled-reply-unread-http.test.ts +212 -0
- package/src/scheduled-requirement-wake-http.test.ts +264 -0
- package/src/scheduled-requirement-wake.ts +125 -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 +312 -0
- package/src/scheduled-stuck.ts +186 -0
- package/src/scheduled-tasks-http.test.ts +638 -0
- package/src/scheduled-tasks.ts +819 -0
- package/src/scheduled-work-outcome-http.test.ts +154 -0
- package/src/scheduled-work-outcome.ts +50 -0
- package/src/scoped-secrets.ts +139 -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 +5398 -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 -2
- package/src/task-access-http.test.ts +402 -0
- package/src/task-access.ts +420 -0
- package/src/task-activity.ts +60 -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 +4008 -355
- 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,193 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { createNetworkTokenForNode, register } from "./auth.js";
|
|
3
|
+
import { db, syncScheduledRunForTask } from "./db.js";
|
|
4
|
+
import { refreshScheduledBackoff } from "./scheduled-backoff.js";
|
|
5
|
+
import { getScheduleRow, dispatchScheduledOccurrence, runDueScheduledTasks } from "./scheduled-tasks.js";
|
|
6
|
+
|
|
7
|
+
if (!process.env.COMMHUB_DB) throw new Error("test1278 requires an explicit disposable SQLite COMMHUB_DB");
|
|
8
|
+
let server: any, base = "", network = "", ownerToken = "", ownerId = "";
|
|
9
|
+
const stamp = Date.now(), nodes: Record<string, { id: string; alias: string; token: string; tokenId: string }> = {};
|
|
10
|
+
async function rest(token: string, method: string, path: string, body?: unknown) {
|
|
11
|
+
const r = await fetch(base + path, { method, headers: { Authorization: `Bearer ${token}`,
|
|
12
|
+
"Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
|
|
13
|
+
return { status: r.status, body: await r.json() as any };
|
|
14
|
+
}
|
|
15
|
+
async function mcp(token: string, name: string, args: Record<string, unknown>) {
|
|
16
|
+
const r = await fetch(base + "/mcp", { method: "POST", headers: { Authorization: `Bearer ${token}`,
|
|
17
|
+
"Content-Type": "application/json", Accept: "application/json, text/event-stream" },
|
|
18
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }) });
|
|
19
|
+
const raw = await r.text(), line = raw.split("\n").find(x => x.startsWith("data: "));
|
|
20
|
+
const envelope = JSON.parse(line ? line.slice(6) : raw);
|
|
21
|
+
try { return JSON.parse(envelope.result.content[0].text); }
|
|
22
|
+
catch { return { ok: false, transportError: true }; }
|
|
23
|
+
}
|
|
24
|
+
const readRun = (taskId: string) => db.get<any>(
|
|
25
|
+
"SELECT run_id, schedule_id, status, work_outcome, work_outcome_node_id, schedule_revision FROM scheduled_task_runs WHERE task_id = ?1 AND network_id = ?2", taskId, network)!;
|
|
26
|
+
const readTask = (taskId: string) => db.get<any>("SELECT status, result FROM tasks WHERE task_id = ?1", taskId)!;
|
|
27
|
+
const countReplies = (taskId: string) => db.get<{ n: number }>("SELECT COUNT(*) AS n FROM inbox WHERE in_reply_to = ?1 AND type = 'reply'", taskId)!.n;
|
|
28
|
+
async function fire(enabled = true, interval = 120) {
|
|
29
|
+
const created = await rest(ownerToken, "POST", "/api/scheduled-tasks", { network_id: network,
|
|
30
|
+
name: `test1278-${crypto.randomUUID()}`, target_node_id: nodes.a.id, task: "fixture1278 work",
|
|
31
|
+
schedule: { type: "interval", every_seconds: interval }, timezone: "UTC", no_change_backoff: enabled });
|
|
32
|
+
expect(created.status).toBe(201);
|
|
33
|
+
const scheduleId = created.body.schedule.schedule_id;
|
|
34
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id != ?2", [new Date(Date.now() + 86400_000).toISOString(), scheduleId]);
|
|
35
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [new Date(Date.now() - 1000).toISOString(), scheduleId]);
|
|
36
|
+
await runDueScheduledTasks();
|
|
37
|
+
const run = db.get<any>("SELECT task_id FROM scheduled_task_runs WHERE schedule_id = ?1", scheduleId)!;
|
|
38
|
+
expect(run.task_id).toBeTruthy();
|
|
39
|
+
return { taskId: run.task_id as string, scheduleId };
|
|
40
|
+
}
|
|
41
|
+
const reply = (taskId: string, meta?: unknown, status = "replied", token = nodes.a.token, extra = {}) =>
|
|
42
|
+
mcp(token, "send_reply", { in_reply_to: taskId, text: "本轮无新增", status, ...(meta === undefined ? {} : { meta }), ...extra });
|
|
43
|
+
const cadence = (id: string) => db.get<any>("SELECT next_run_at, last_run_at, revision, schedule_json, target_node_id FROM scheduled_tasks WHERE schedule_id = ?1", id)!;
|
|
44
|
+
|
|
45
|
+
beforeAll(async () => {
|
|
46
|
+
const owner = register(`work_owner_${stamp}`, "Fixture1274Only!x"); ownerToken = owner.token!;
|
|
47
|
+
network = owner.network_id!; ownerId = owner.user!.user_id;
|
|
48
|
+
for (const key of ["a", "b"]) {
|
|
49
|
+
const id = `n_work_${key}_${stamp}`, alias = `work-${key}-${stamp}`;
|
|
50
|
+
const token = createNetworkTokenForNode(ownerId, network, alias, id); expect(token.ok).toBe(true);
|
|
51
|
+
nodes[key] = { id, alias, token: token.token!, tokenId: token.token_id! };
|
|
52
|
+
db.run("INSERT INTO sessions (resume_id, alias, status, node_id, network_id) VALUES (?1, ?2, 'idle', ?3, ?4)", [`r_${id}`, alias, id, network]);
|
|
53
|
+
}
|
|
54
|
+
const mod = await import("./server.js"); server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
55
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
56
|
+
}, 30_000);
|
|
57
|
+
afterAll(() => server?.stop?.(true));
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
const state = (id: string) => db.get<any>("SELECT no_change_backoff, backoff_streak, backoff_effective_seconds, backoff_last_run_id, next_run_at, revision, schedule_json, status FROM scheduled_tasks WHERE schedule_id = ?1", id)!;
|
|
61
|
+
let occurrence = 0;
|
|
62
|
+
async function nextRun(id: string) {
|
|
63
|
+
// Isolated fixture accelerates the clock, without touching live schedules.
|
|
64
|
+
const date = new Date(Date.now() + (++occurrence) * 1000).toISOString();
|
|
65
|
+
const out = dispatchScheduledOccurrence(getScheduleRow(id)!, date, true);
|
|
66
|
+
expect(out.taskId).toBeTruthy(); return out.taskId!;
|
|
67
|
+
}
|
|
68
|
+
async function empties(id: string, n: number) {
|
|
69
|
+
for (let i = 0; i < n; i++) expect((await reply(await nextRun(id), { scheduled_work_outcome: "no_changes" })).ok).toBe(true);
|
|
70
|
+
}
|
|
71
|
+
async function atThree() {
|
|
72
|
+
const x = await fire(); expect((await reply(x.taskId, { scheduled_work_outcome: "no_changes" })).ok).toBe(true);
|
|
73
|
+
await empties(x.scheduleId, 2); return x;
|
|
74
|
+
}
|
|
75
|
+
describe("#1278 opt-in durable no-change backoff through real HTTP/MCP", () => {
|
|
76
|
+
test("3 and 6 exact empty successes change effective cadence, preserving base and revision", async () => {
|
|
77
|
+
const { taskId, scheduleId } = await fire(), initial = state(scheduleId);
|
|
78
|
+
expect((await reply(taskId, { scheduled_work_outcome: "no_changes" })).ok).toBe(true);
|
|
79
|
+
await empties(scheduleId, 1); expect(state(scheduleId).backoff_effective_seconds).toBe(120);
|
|
80
|
+
await empties(scheduleId, 1); const three = state(scheduleId);
|
|
81
|
+
expect(three).toMatchObject({ backoff_streak: 3, backoff_effective_seconds: 600, revision: initial.revision, schedule_json: initial.schedule_json });
|
|
82
|
+
expect(Date.parse(three.next_run_at) - Date.now()).toBeGreaterThan(590_000);
|
|
83
|
+
await empties(scheduleId, 3); expect(state(scheduleId)).toMatchObject({ backoff_streak: 6, backoff_effective_seconds: 1800 });
|
|
84
|
+
const wire = await rest(ownerToken, "GET", `/api/scheduled-tasks/${scheduleId}`);
|
|
85
|
+
expect(wire.body.schedule).toMatchObject({ no_change_backoff: true, schedule: { every_seconds: 120 }, backoff: { streak: 6, effective_seconds: 1800 } });
|
|
86
|
+
expect(wire.body.schedule.backoff_last_run_id).toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
test("changed restores base and nearer next occurrence; no natural-language inference", async () => {
|
|
89
|
+
const { scheduleId } = await atThree();
|
|
90
|
+
expect((await reply(await nextRun(scheduleId), { scheduled_work_outcome: "changed" })).ok).toBe(true);
|
|
91
|
+
expect(state(scheduleId)).toMatchObject({ backoff_streak: 0, backoff_effective_seconds: 120 });
|
|
92
|
+
expect(Date.parse(state(scheduleId).next_run_at) - Date.now()).toBeLessThanOrEqual(120_000);
|
|
93
|
+
});
|
|
94
|
+
test("unknown success and ordinary failure break the sequence", async () => {
|
|
95
|
+
for (const status of ["replied", "failed"]) {
|
|
96
|
+
const { scheduleId } = await atThree();
|
|
97
|
+
expect((await reply(await nextRun(scheduleId), undefined, status)).ok).toBe(true);
|
|
98
|
+
expect(state(scheduleId)).toMatchObject({ backoff_streak: 0, backoff_effective_seconds: 120 });
|
|
99
|
+
await empties(scheduleId, 2); expect(state(scheduleId).backoff_effective_seconds).toBe(120);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
test("legacy disabled schedules preserve exact cadence and wire shape", async () => {
|
|
103
|
+
const { taskId, scheduleId } = await fire(false), before = state(scheduleId);
|
|
104
|
+
expect((await reply(taskId, { scheduled_work_outcome: "no_changes" })).ok).toBe(true);
|
|
105
|
+
expect(state(scheduleId)).toEqual(before);
|
|
106
|
+
const wire = await rest(ownerToken, "GET", `/api/scheduled-tasks/${scheduleId}`);
|
|
107
|
+
expect(wire.body.schedule.no_change_backoff).toBeUndefined(); expect(wire.body.schedule.backoff).toBeUndefined();
|
|
108
|
+
});
|
|
109
|
+
test("a slower base interval is never accelerated", async () => {
|
|
110
|
+
const { taskId, scheduleId } = await fire(true, 3600);
|
|
111
|
+
await reply(taskId, { scheduled_work_outcome: "no_changes" }); await empties(scheduleId, 5);
|
|
112
|
+
expect(state(scheduleId)).toMatchObject({ backoff_streak: 6, backoff_effective_seconds: 3600 });
|
|
113
|
+
});
|
|
114
|
+
test("duplicate callbacks and another DB process cannot increment twice", async () => {
|
|
115
|
+
const { scheduleId } = await atThree(), before = state(scheduleId);
|
|
116
|
+
const latest = db.get<any>("SELECT task_id FROM scheduled_task_runs WHERE run_id = ?1", before.backoff_last_run_id)!;
|
|
117
|
+
expect((await reply(latest.task_id, { scheduled_work_outcome: "no_changes" })).ok).toBe(false);
|
|
118
|
+
syncScheduledRunForTask(latest.task_id, network); expect(state(scheduleId)).toEqual(before);
|
|
119
|
+
const child = Bun.spawn([process.execPath, "-e", `import {db} from './server/src/db.ts'; import {refreshScheduledBackoff} from './server/src/scheduled-backoff.ts'; db.transaction(()=>refreshScheduledBackoff(${JSON.stringify(scheduleId)},${JSON.stringify(network)})); console.log(JSON.stringify(db.get('SELECT backoff_streak,backoff_effective_seconds FROM scheduled_tasks WHERE schedule_id=?1',${JSON.stringify(scheduleId)})));`], { cwd: process.cwd(), stdout: "pipe", stderr: "pipe", env: process.env });
|
|
120
|
+
const output = await new Response(child.stdout).text(); expect(await child.exited).toBe(0);
|
|
121
|
+
expect(JSON.parse(output.trim().split("\n").at(-1)!)).toEqual({ backoff_streak: 3, backoff_effective_seconds: 600 });
|
|
122
|
+
expect(state(scheduleId)).toEqual(before);
|
|
123
|
+
});
|
|
124
|
+
test("old generation completion after edit cannot rearm", async () => {
|
|
125
|
+
const { scheduleId } = await atThree(), old = await nextRun(scheduleId), revision = state(scheduleId).revision;
|
|
126
|
+
const edit = await rest(ownerToken, "PATCH", `/api/scheduled-tasks/${scheduleId}`, { revision, name: "fresh generation" }); expect(edit.status).toBe(200);
|
|
127
|
+
const before = state(scheduleId); expect(before.backoff_streak).toBe(0);
|
|
128
|
+
expect((await reply(old, { scheduled_work_outcome: "no_changes" })).ok).toBe(true); expect(state(scheduleId)).toEqual(before);
|
|
129
|
+
});
|
|
130
|
+
test("manual wake resets durable state and fences an in-flight old generation", async () => {
|
|
131
|
+
const { scheduleId } = await atThree(), old = await nextRun(scheduleId), revision = state(scheduleId).revision;
|
|
132
|
+
// Existing overlap skip remains effective. Wake still resets cadence.
|
|
133
|
+
const wake = await rest(ownerToken, "POST", `/api/scheduled-tasks/${scheduleId}/run-now`); expect(wake.status).toBe(202);
|
|
134
|
+
expect(state(scheduleId)).toMatchObject({ backoff_streak: 0, backoff_effective_seconds: null, revision: revision + 1 });
|
|
135
|
+
const before = state(scheduleId); await reply(old, { scheduled_work_outcome: "no_changes" }); expect(state(scheduleId)).toEqual(before);
|
|
136
|
+
});
|
|
137
|
+
test("pause/cancel never resurrect cadence; resumed policy starts a new generation", async () => {
|
|
138
|
+
const { scheduleId } = await atThree(), old = await nextRun(scheduleId);
|
|
139
|
+
expect((await rest(ownerToken, "PATCH", `/api/scheduled-tasks/${scheduleId}`, { revision: state(scheduleId).revision, status: "paused" })).status).toBe(200);
|
|
140
|
+
await reply(old, { scheduled_work_outcome: "changed" }); expect(state(scheduleId)).toMatchObject({ status: "paused", next_run_at: null, backoff_streak: 0 });
|
|
141
|
+
expect((await rest(ownerToken, "POST", `/api/scheduled-tasks/${scheduleId}/run-now`)).status).toBe(202);
|
|
142
|
+
expect(state(scheduleId).next_run_at).toBeNull();
|
|
143
|
+
expect((await rest(ownerToken, "DELETE", `/api/scheduled-tasks/${scheduleId}`)).status).toBe(200);
|
|
144
|
+
expect((await rest(ownerToken, "POST", `/api/scheduled-tasks/${scheduleId}/run-now`)).status).toBe(409);
|
|
145
|
+
expect(state(scheduleId)).toMatchObject({ status: "cancelled", next_run_at: null });
|
|
146
|
+
});
|
|
147
|
+
test("retry success is excluded from empty streaks and failure breaks prior successes", async () => {
|
|
148
|
+
const { scheduleId } = await atThree(), task = await nextRun(scheduleId);
|
|
149
|
+
await reply(task, undefined, "failed"); expect(state(scheduleId).backoff_streak).toBe(0);
|
|
150
|
+
expect((await mcp(ownerToken, "retry_task", { task_id: task, network_id: network })).ok).toBe(true);
|
|
151
|
+
expect((await reply(task, { scheduled_work_outcome: "no_changes" })).ok).toBe(true);
|
|
152
|
+
expect(state(scheduleId).backoff_streak).toBe(0); await empties(scheduleId, 2);
|
|
153
|
+
expect(state(scheduleId).backoff_streak).toBe(2);
|
|
154
|
+
});
|
|
155
|
+
test("overlap skip breaks the sequence and an older completion cannot bypass the skip", async () => {
|
|
156
|
+
const { scheduleId } = await atThree(), active = await nextRun(scheduleId);
|
|
157
|
+
const skipped = dispatchScheduledOccurrence(getScheduleRow(scheduleId)!, new Date(Date.now() + (++occurrence) * 1000).toISOString(), true);
|
|
158
|
+
expect(skipped.status).toBe("skipped"); expect(state(scheduleId).backoff_streak).toBe(0);
|
|
159
|
+
await reply(active, { scheduled_work_outcome: "no_changes" }); expect(state(scheduleId).backoff_streak).toBe(0);
|
|
160
|
+
});
|
|
161
|
+
test("current reporter target mismatch cannot count as empty", async () => {
|
|
162
|
+
const { scheduleId } = await atThree(), task = await nextRun(scheduleId);
|
|
163
|
+
// Fixture-only reassignment emulates an authorized assignment change.
|
|
164
|
+
db.run("UPDATE tasks SET to_node_id = ?1, to_name = ?2 WHERE task_id = ?3", [nodes.b.id, nodes.b.alias, task]);
|
|
165
|
+
expect((await reply(task, { scheduled_work_outcome: "no_changes" }, "replied", nodes.b.token)).ok).toBe(true);
|
|
166
|
+
expect(state(scheduleId).backoff_streak).toBe(0);
|
|
167
|
+
});
|
|
168
|
+
test("injected cadence-write fault rolls back task, inbox, outcome, and state together", async () => {
|
|
169
|
+
const { taskId, scheduleId } = await fire(), before = state(scheduleId);
|
|
170
|
+
db.exec(`CREATE TRIGGER test1278_fault BEFORE UPDATE OF backoff_streak ON scheduled_tasks WHEN NEW.schedule_id='${scheduleId}' BEGIN SELECT RAISE(ABORT,'test1278_fault'); END`);
|
|
171
|
+
try { expect((await reply(taskId, { scheduled_work_outcome: "no_changes" })).ok).not.toBe(true); }
|
|
172
|
+
finally { db.exec("DROP TRIGGER test1278_fault"); }
|
|
173
|
+
expect(state(scheduleId)).toEqual(before); expect(readTask(taskId).status).toBe("delivered");
|
|
174
|
+
expect(readRun(taskId).work_outcome).toBeNull(); expect(countReplies(taskId)).toBe(0);
|
|
175
|
+
expect((await reply(taskId, { scheduled_work_outcome: "no_changes" })).ok).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
test("REST rejects malformed and non-interval policy; MCP respects creator ownership", async () => {
|
|
178
|
+
for (const policy of ["true", 1, null]) {
|
|
179
|
+
const bad = await rest(ownerToken, "POST", "/api/scheduled-tasks", { network_id: network, name: "invalid1278", target_node_id: nodes.a.id, task: "fixture", schedule: { type: "interval", every_seconds: 120 }, no_change_backoff: policy }); expect(bad.status).toBe(400);
|
|
180
|
+
}
|
|
181
|
+
const bad = await rest(ownerToken, "POST", "/api/scheduled-tasks", { network_id: network, name: "once1278", target_node_id: nodes.a.id, task: "fixture", schedule: { type: "once", run_at: new Date(Date.now()+86400000).toISOString() }, no_change_backoff: true }); expect(bad.status).toBe(400);
|
|
182
|
+
const created = await mcp(nodes.a.token, "schedule_create", { name: "agent1278", task: "fixture", schedule: { type: "interval", every_seconds: 120 }, no_change_backoff: true }); expect(created.ok).toBe(true);
|
|
183
|
+
expect(created.schedule.no_change_backoff).toBe(true);
|
|
184
|
+
const id = created.schedule.schedule_id;
|
|
185
|
+
expect((await mcp(nodes.b.token, "schedule_update", { schedule_id: id, no_change_backoff: false })).ok).toBe(false);
|
|
186
|
+
expect((await mcp(nodes.a.token, "schedule_update", { schedule_id: id, no_change_backoff: false })).ok).toBe(true);
|
|
187
|
+
expect(state(id).no_change_backoff).toBe(0);
|
|
188
|
+
const human = await fire(); expect((await mcp(nodes.a.token, "schedule_update", { schedule_id: human.scheduleId, no_change_backoff: false })).ok).toBe(false);
|
|
189
|
+
db.run("UPDATE scheduled_tasks SET overlap_policy = 'allow' WHERE schedule_id = ?1", human.scheduleId);
|
|
190
|
+
const allow = await rest(ownerToken, "PATCH", `/api/scheduled-tasks/${human.scheduleId}`, { revision: state(human.scheduleId).revision, no_change_backoff: true });
|
|
191
|
+
expect(allow.status).toBe(400); expect(allow.body.error).toBe("no_change_backoff_requires_skip_overlap");
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { db } from "./db.js";
|
|
2
|
+
|
|
3
|
+
type WakeHook = (id: string, network: string, now: Date) => boolean;
|
|
4
|
+
let requirementWakeHook: WakeHook | null = null;
|
|
5
|
+
export function setRequirementWakeHook(hook: WakeHook): void { requirementWakeHook = hook; }
|
|
6
|
+
|
|
7
|
+
export function parseBackoffPolicy(raw: unknown, type: string, fallback = false): boolean {
|
|
8
|
+
if (raw !== undefined && typeof raw !== "boolean") throw new Error("invalid_no_change_backoff");
|
|
9
|
+
const enabled = raw === undefined ? fallback : raw as boolean;
|
|
10
|
+
if (enabled && type !== "interval") throw new Error("no_change_backoff_requires_interval");
|
|
11
|
+
return enabled;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** SQLite only. Called in the reply/dispatch transaction; no catch-and-continue
|
|
15
|
+
* on state-write failure. Deriving a capped streak from authoritative runs
|
|
16
|
+
* makes duplicate or out-of-order callbacks unable to increment it twice. */
|
|
17
|
+
export function refreshScheduledBackoff(scheduleId: string, networkId: string, now = new Date()): void {
|
|
18
|
+
if (db.transactionalFeaturesRefusal !== null) return;
|
|
19
|
+
if (requirementWakeHook?.(scheduleId, networkId, now)) return;
|
|
20
|
+
const row = db.get<any>(`SELECT status, revision, target_node_id, schedule_json,
|
|
21
|
+
no_change_backoff, overlap_policy, requirement_wake_json, backoff_streak, backoff_effective_seconds,
|
|
22
|
+
backoff_last_run_id, next_run_at FROM scheduled_tasks
|
|
23
|
+
WHERE schedule_id = ?1 AND network_id = ?2`, scheduleId, networkId);
|
|
24
|
+
if (!row || row.status !== "active" || row.no_change_backoff !== 1 || row.overlap_policy !== "skip") return;
|
|
25
|
+
// A DB-only process with no watcher loaded may not bypass its fence.
|
|
26
|
+
if (row.requirement_wake_json && !requirementWakeHook) return;
|
|
27
|
+
const spec = JSON.parse(row.schedule_json);
|
|
28
|
+
if (spec.type !== "interval") return;
|
|
29
|
+
// Include failures/skips/unknowns: excluding them would turn separated
|
|
30
|
+
// successes into a fictitious consecutive empty sequence. Six is a cap.
|
|
31
|
+
const runs = db.all<any>(`SELECT r.run_id, r.status, r.work_outcome,
|
|
32
|
+
r.schedule_revision, r.work_outcome_node_id, r.backoff_excluded,
|
|
33
|
+
t.status AS task_status, t.to_node_id, t.from_name
|
|
34
|
+
FROM scheduled_task_runs r LEFT JOIN tasks t
|
|
35
|
+
ON t.task_id = r.task_id AND t.network_id = r.network_id
|
|
36
|
+
WHERE r.schedule_id = ?1 AND r.network_id = ?2
|
|
37
|
+
ORDER BY r.scheduled_for DESC, r.created_at DESC, r.run_id DESC LIMIT 6`, scheduleId, networkId);
|
|
38
|
+
const latest = runs[0];
|
|
39
|
+
if (!latest || latest.schedule_revision !== row.revision
|
|
40
|
+
|| latest.run_id === row.backoff_last_run_id
|
|
41
|
+
|| ["claiming", "queued", "delivered"].includes(latest.status)) return;
|
|
42
|
+
let streak = 0;
|
|
43
|
+
for (const run of runs) {
|
|
44
|
+
if (run.schedule_revision !== row.revision || run.status !== "replied"
|
|
45
|
+
|| run.task_status !== "replied" || run.work_outcome !== "no_changes"
|
|
46
|
+
|| run.work_outcome_node_id !== row.target_node_id || run.to_node_id !== row.target_node_id
|
|
47
|
+
|| run.from_name !== "scheduler" || run.backoff_excluded !== 0) break;
|
|
48
|
+
streak++;
|
|
49
|
+
}
|
|
50
|
+
const effective = Math.max(spec.every_seconds, streak >= 6 ? 1800 : streak >= 3 ? 600 : 0);
|
|
51
|
+
const previous = row.backoff_effective_seconds ?? spec.every_seconds;
|
|
52
|
+
let next = row.next_run_at;
|
|
53
|
+
if (effective !== previous && next) {
|
|
54
|
+
const candidate = now.getTime() + effective * 1000;
|
|
55
|
+
next = new Date(effective > previous ? Math.max(Date.parse(next), candidate)
|
|
56
|
+
: Math.min(Date.parse(next), candidate)).toISOString();
|
|
57
|
+
}
|
|
58
|
+
const result = db.run(`UPDATE scheduled_tasks SET backoff_streak = ?1,
|
|
59
|
+
backoff_effective_seconds = ?2, backoff_last_run_id = ?3, next_run_at = ?4,
|
|
60
|
+
updated_at = datetime('now') WHERE schedule_id = ?5 AND network_id = ?6
|
|
61
|
+
AND revision = ?7 AND status = 'active' AND no_change_backoff = 1`,
|
|
62
|
+
[streak, effective, latest.run_id, next, scheduleId, networkId, row.revision]);
|
|
63
|
+
if (result.changes !== 1) throw new Error("scheduled_backoff_atomic_write_failed");
|
|
64
|
+
}
|
|
@@ -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
|
+
});
|