@sleep2agi/commhub-server 0.9.0-preview.133 → 0.9.0-preview.134

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.133",
3
+ "version": "0.9.0-preview.134",
4
4
  "description": "CommHub Server \u2014 AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -16,7 +16,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
16
  import { addNetworkMember, createNetworkTokenForNode, register } from "./auth.js";
17
17
  import { db } from "./db.js";
18
18
  import { dispatchScheduledOccurrence, handleScheduledTaskRequest } from "./scheduled-tasks.js";
19
- import { SCHEDULE_STUCK_NOTICE_KIND, stuckTimeoutMs } from "./scheduled-stuck.js";
19
+ import { blockerTimedOut, SCHEDULE_STUCK_NOTICE_KIND, stuckTimeoutMs } from "./scheduled-stuck.js";
20
20
  import { registerTools } from "./tools.js";
21
21
 
22
22
  const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
@@ -236,3 +236,53 @@ describe("#464 history clarity", () => {
236
236
  expect(skipped.blocked_by_state).toBe("not_received");
237
237
  });
238
238
  });
239
+
240
+
241
+ describe("#1112 execution clock", () => {
242
+ const ts = (now: Date, minutes: number) => new Date(now.getTime() - minutes * 60_000).toISOString();
243
+ test("queue budget: creation over one hour but consumption under one hour keeps the exact run open", () => {
244
+ const s = seedSchedule();
245
+ const first = fire(s);
246
+ const now = new Date();
247
+ db.run("UPDATE tasks SET status = 'running', created_at = ?1, consumed_at = ?2 WHERE task_id = ?3", [ts(now, 61), ts(now, 49), first.taskId]);
248
+ expect(fire(s, now).status).toBe("skipped");
249
+ expect(taskRow(first.taskId!).status).toBe("running");
250
+ expect(runRow(first.runId).status).toBe("delivered");
251
+ expect(db.get<any>("SELECT COUNT(*) AS n FROM task_events WHERE task_id = ?1 AND to_status = 'expired'", first.taskId).n).toBe(0);
252
+ db.run("UPDATE tasks SET created_at = ?1, consumed_at = ?2 WHERE task_id = ?3", [ts(now, 73), ts(now, 61), first.taskId]);
253
+ const later = fire(s, now);
254
+ expect(later.status).toBe("delivered");
255
+ expect(taskRow(first.taskId!).status).toBe("expired");
256
+ expect(runRow(first.runId).error_message).toContain("since consumed_at");
257
+ });
258
+ test("queue budget: legacy execution start protects the queued task, consumption takes precedence", () => {
259
+ const s = seedSchedule(); const first = fire(s); const now = new Date();
260
+ db.run("UPDATE tasks SET status = 'running', created_at = ?1, started_at = ?2 WHERE task_id = ?3", [ts(now, 75), ts(now, 40), first.taskId]);
261
+ expect(fire(s, now).status).toBe("skipped");
262
+ db.run("UPDATE tasks SET started_at = ?1, consumed_at = ?2 WHERE task_id = ?3", [ts(now, 70), ts(now, 40), first.taskId]);
263
+ expect(fire(s, now).status).toBe("skipped");
264
+ });
265
+ test("submission and ACK alone do not renew queue time", () => {
266
+ const s = seedSchedule(); const first = fire(s); const now = new Date();
267
+ db.run("UPDATE tasks SET status = 'acked', created_at = ?1, runtime_submitted_at = ?2 WHERE task_id = ?3", [ts(now, 61), ts(now, 1), first.taskId]);
268
+ expect(fire(s, now).status).toBe("delivered");
269
+ expect(runRow(first.runId).error_message).toContain("since created_at");
270
+ });
271
+ test("total cap: a very late consumption cannot keep an occurrence open beyond 24 hours", () => {
272
+ const s = seedSchedule(); const first = fire(s); const now = new Date();
273
+ db.run("UPDATE tasks SET status = 'running', created_at = ?1, consumed_at = ?2 WHERE task_id = ?3", [ts(now, 1441), ts(now, 1), first.taskId]);
274
+ expect(fire(s, now).status).toBe("delivered");
275
+ expect(runRow(first.runId).status).toBe("expired");
276
+ expect(runRow(first.runId).error_message).toContain("created_at (total cap)");
277
+ expect(runRow(first.runId).error_message).toContain("limit 1440 min");
278
+ });
279
+ test("invalid, future and pre-creation evidence falls back; UTC and exact execution boundary remain stable", () => {
280
+ const now = new Date("2026-10-13T03:00:00Z");
281
+ const schedule = JSON.stringify({ type: "interval", every_seconds: 60 });
282
+ for (const consumed_at of [null, "invalid", "2026-10-14T00:00:00Z", "2026-10-12T00:00:00Z"]) {
283
+ expect(blockerTimedOut({ created_at: "2026-10-13 01:00:00", consumed_at }, schedule, now)).toBe(true);
284
+ }
285
+ expect(blockerTimedOut({ created_at: "2026-10-13 01:00:00", consumed_at: "2026-10-13 02:00:01" }, schedule, now)).toBe(false);
286
+ expect(blockerTimedOut({ created_at: "2026-10-13 01:00:00", consumed_at: "2026-10-13 02:00:00" }, schedule, now)).toBe(true);
287
+ });
288
+ });
@@ -14,6 +14,8 @@
14
14
  // 恰好一条通知。
15
15
  //
16
16
  // 默认值(PR 里有推导):N=3;超时 = clamp(6 × 间隔, 下限 1 小时, 上限 24 小时)。
17
+ // #1112:从 consumed_at 起算,旧节点回落 started_at,未执行回落 created_at;
18
+ // 自创建时间的总保护上限仍为 cap,节点心跳/提交/ACK 不刷新执行时钟。
17
19
  // 下限 1 小时:一轮 agent 任务正常要跑几十分钟,不能因为排程间隔短就把还在干活的任务掐掉。
18
20
  // 上限 24 小时:Hub 本来就给定时任务 24 小时的 expires_at(没被取走时由巡检过期),开着超过一天的
19
21
  // 定时任务不是「还在做」。6 × 间隔:间隔长的排程(每小时一次)给它 6 小时,而不是 1 小时。
@@ -54,20 +56,44 @@ export function stuckTimeoutMs(scheduleJson: string): number {
54
56
  return Math.min(cap, Math.max(floor, factor * nominalIntervalSeconds(scheduleJson))) * 1000;
55
57
  }
56
58
 
57
- type Blocker = { task_id: string; created_at: string; status: string };
59
+ export type StuckBlockerClock = {
60
+ created_at: string;
61
+ consumed_at?: string | null;
62
+ started_at?: string | null;
63
+ };
64
+
65
+ /** #1105: queue time is not execution time. Submission/ACK/node heartbeat are not consumption. */
66
+ export function blockerTimeoutClock(blocker: StuckBlockerClock, scheduleJson: string, now: Date): { ageMs: number; limitMs: number; source: string } {
67
+ const created = parseDbTimestampMs(blocker.created_at);
68
+ const nowMs = now.getTime();
69
+ const floor = envNumber("COMMHUB_SCHEDULE_STUCK_TIMEOUT_FLOOR_SEC", 3_600, 1);
70
+ const capMs = Math.max(floor, envNumber("COMMHUB_SCHEDULE_STUCK_TIMEOUT_CAP_SEC", 86_400, 1)) * 1000;
71
+ // Even a very late consumption cannot hold this occurrence beyond the total cap.
72
+ if (Number.isFinite(created) && nowMs - created >= capMs) {
73
+ return { ageMs: nowMs - created, limitMs: capMs, source: "created_at (total cap)" };
74
+ }
75
+ for (const source of ["consumed_at", "started_at"] as const) {
76
+ const value = blocker[source];
77
+ if (!value) continue;
78
+ const at = parseDbTimestampMs(value);
79
+ if (Number.isFinite(at) && at <= nowMs && (!Number.isFinite(created) || at >= created)) {
80
+ return { ageMs: nowMs - at, limitMs: stuckTimeoutMs(scheduleJson), source };
81
+ }
82
+ }
83
+ return { ageMs: nowMs - created, limitMs: stuckTimeoutMs(scheduleJson), source: "created_at" };
84
+ }
58
85
 
59
86
  /** 挡路的任务开了多久已经到超时 → true。读不出时间 = 不超时(宁可多挡一次,不错杀)。 */
60
- export function blockerTimedOut(blocker: Pick<Blocker, "created_at">, scheduleJson: string, now: Date): boolean {
61
- const started = parseDbTimestampMs(blocker.created_at);
62
- if (!Number.isFinite(started)) return false;
63
- return now.getTime() - started >= stuckTimeoutMs(scheduleJson);
87
+ export function blockerTimedOut(blocker: StuckBlockerClock, scheduleJson: string, now: Date): boolean {
88
+ const clock = blockerTimeoutClock(blocker, scheduleJson, now);
89
+ return Number.isFinite(clock.ageMs) && clock.ageMs >= clock.limitMs;
64
90
  }
65
91
 
66
92
  /**
67
93
  * 在调用方的事务里按超时结束挡路的任务。只动还开着的任务(条件写),返回是否真的结束了它。
68
94
  * 与 TTL 巡检(server.ts patrolExpiredTasks)同一套写法:status=expired、收件箱行置 acked、镜像到 run。
69
95
  */
70
- export function expireStuckBlocker(taskId: string, networkId: string, ageMs: number, limitMs: number): boolean {
96
+ export function expireStuckBlocker(taskId: string, networkId: string, ageMs: number, limitMs: number, source = "created_at"): boolean {
71
97
  const changed = db.run(
72
98
  `UPDATE tasks SET status = 'expired', completed_at = datetime('now')
73
99
  WHERE task_id = ?1 AND network_id = ?2 AND status IN ('created', 'delivered', 'acked', 'running')`,
@@ -78,7 +104,7 @@ export function expireStuckBlocker(taskId: string, networkId: string, ageMs: num
78
104
  syncScheduledRunForTask(taskId, networkId);
79
105
  db.run(
80
106
  "UPDATE scheduled_task_runs SET error_message = ?1 WHERE task_id = ?2 AND network_id = ?3",
81
- [`timed out by the scheduler: open for ${Math.round(ageMs / 60_000)} min (limit ${Math.round(limitMs / 60_000)} min); the next occurrence was dispatched`, taskId, networkId],
107
+ [`timed out by the scheduler: ${Math.round(ageMs / 60_000)} min since ${source} (limit ${Math.round(limitMs / 60_000)} min); the next occurrence was dispatched`, taskId, networkId],
82
108
  );
83
109
  return true;
84
110
  }
@@ -127,12 +153,12 @@ export function stuckNoticeText(n: StuckNotice): { title: string; text: string }
127
153
  return {
128
154
  title: "定时任务被卡住",
129
155
  text: `定时任务「${n.scheduleName}」被上一次的执行卡住了:任务 ${n.taskId} 于 ${started} 派给节点 ${n.alias},到现在还没有完成,之后的 ${n.skips} 次执行都被跳过。\n\n`
130
- + `它开着超过 ${fmtDuration(n.timeoutMs)} 后会被自动按超时结束,下一次照常派发。等不及可以在任务列表里取消这条任务。`,
156
+ + `从实际开始执行起超过 ${fmtDuration(n.timeoutMs)} 后会被自动按超时结束;尚未开始的任务从派发起计时,所有执行仍受总时限保护。下一次照常派发。等不及可以在任务列表里取消这条任务。`,
131
157
  };
132
158
  }
133
159
  return {
134
160
  title: "定时任务已超时放行",
135
- text: `定时任务「${n.scheduleName}」上一次的任务 ${n.taskId}(${started} 派给节点 ${n.alias})开着超过 ${fmtDuration(n.timeoutMs)} 还没有完成,已按超时结束,不再挡后面的执行。`,
161
+ text: `定时任务「${n.scheduleName}」上一次的任务 ${n.taskId}(${started} 派给节点 ${n.alias})超过 ${fmtDuration(n.timeoutMs)} 的执行或总保护时限还没有完成,已按超时结束,不再挡后面的执行。`,
136
162
  };
137
163
  }
138
164
 
@@ -8,8 +8,7 @@ import { canMessageAgent, type AgentRef } from "./agent-access.js";
8
8
  import { dispatchVerdict } from "./node-permissions.js";
9
9
  import { hasSubscribers, pushEvent, pushNetworkObserverEvent } from "./push.js";
10
10
  import { SCHEDULED_TASK_STORAGE_SELECT } from "./rest-projections.js";
11
- import { parseDbTimestampMs } from "./db-timestamp.js";
12
- import { blockerTimedOut, expireStuckBlocker, sendStuckNotice, skipsBlockedBy, stuckNoticeSkips, stuckTimeoutMs, type StuckNotice } from "./scheduled-stuck.js";
11
+ import { blockerTimedOut, blockerTimeoutClock, expireStuckBlocker, sendStuckNotice, skipsBlockedBy, stuckNoticeSkips, stuckTimeoutMs, type StuckNotice } from "./scheduled-stuck.js";
13
12
  import { evaluateScheduleFailures, failureSummary } from "./scheduled-failures.js";
14
13
  import { scheduledTargetOffline, sendOfflineNotice, type OfflineNotice } from "./scheduled-offline.js";
15
14
 
@@ -413,8 +412,8 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
413
412
  // received:节点那边有没有把这条任务取走过(inbox 行被 ack,或任务已进 acked/running)。
414
413
  // 跳过记录带上它,客户端才分得清「节点还没收到」和「节点在处理」—— 前者是投递故障,
415
414
  // 后者才是真的上一次还没做完。
416
- const findOpen = () => db.get<{ task_id: string; received: number; created_at: string; status: string }>(
417
- `SELECT r.task_id, t.created_at, t.status,
415
+ const findOpen = () => db.get<{ task_id: string; received: number; created_at: string; consumed_at: string | null; started_at: string | null; status: string }>(
416
+ `SELECT r.task_id, t.created_at, t.consumed_at, t.started_at, t.status,
418
417
  CASE WHEN t.status IN ('acked', 'running')
419
418
  OR EXISTS (SELECT 1 FROM inbox i WHERE (i.task_id = r.task_id OR i.id = r.task_id) AND i.acked = 1)
420
419
  THEN 1 ELSE 0 END AS received
@@ -428,9 +427,8 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
428
427
  // #464 — a blocker open past the stuck timeout is ended as `expired` (the existing terminal state) and
429
428
  // this occurrence dispatches normally. Bounded: each pass ends one exact open task or stops.
430
429
  for (let guard = 0; open && guard < 20 && blockerTimedOut(open, row.schedule_json, advanceAfter); guard++) {
431
- const limitMs = stuckTimeoutMs(row.schedule_json);
432
- const ageMs = advanceAfter.getTime() - parseDbTimestampMs(open.created_at);
433
- if (!expireStuckBlocker(open.task_id, row.network_id, ageMs, limitMs)) break;
430
+ const { ageMs, limitMs, source } = blockerTimeoutClock(open, row.schedule_json, advanceAfter);
431
+ if (!expireStuckBlocker(open.task_id, row.network_id, ageMs, limitMs, source)) break;
434
432
  timedOut.push({ taskId: open.task_id, fromStatus: open.status });
435
433
  // Each stuck episode gets exactly one notice: if the stuck notice already went out (N skips), stay quiet.
436
434
  if (skipsBlockedBy(row.schedule_id, open.task_id) < stuckNoticeSkips()) {