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

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.135",
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",
package/src/db.ts CHANGED
@@ -245,6 +245,7 @@ db.exec(`
245
245
  started_at TEXT,
246
246
  runtime_submitted_at TEXT,
247
247
  consumed_at TEXT,
248
+ last_activity_at TEXT,
248
249
  thread_id TEXT,
249
250
  turn_id TEXT,
250
251
  completed_at TEXT,
@@ -1557,6 +1558,8 @@ try { db.exec("ALTER TABLE tasks ADD COLUMN meta_json TEXT"); } catch {}
1557
1558
  // started_at (legacy report_status/content matching). Old binaries ignore them.
1558
1559
  try { db.exec("ALTER TABLE tasks ADD COLUMN runtime_submitted_at TEXT"); } catch {}
1559
1560
  try { db.exec("ALTER TABLE tasks ADD COLUMN consumed_at TEXT"); } catch {}
1561
+ // #1113: exact original-turn activity. NULL keeps the old execution-clock fallback.
1562
+ try { db.exec("ALTER TABLE tasks ADD COLUMN last_activity_at TEXT"); } catch {}
1560
1563
  // Exact vendor-runtime boundary for SideThread-capable clients. These values
1561
1564
  // are written only by the token-bound target node after attributable runtime
1562
1565
  // evidence; the Hub never derives them from task text or caller input.
@@ -28,6 +28,7 @@ import { join } from "node:path";
28
28
  import { tmpdir } from "node:os";
29
29
  import { addNetworkMember, createNetworkTokenForNode, register } from "./auth.js";
30
30
  import { db, generateNetworkToken, hashToken } from "./db.js";
31
+ import { createTaskRuntimeEvidenceReporter, taskActivityPayload } from "../../agent-node/src/task-runtime-evidence";
31
32
 
32
33
  const dir = mkdtempSync(join(tmpdir(), "anet-caller-identity-"));
33
34
  const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
@@ -984,3 +985,99 @@ test("duplicate ownerless row keeps an unbound epoch 0 token online", async () =
984
985
  first.abort();
985
986
  second.abort();
986
987
  }, 15_000);
988
+
989
+
990
+ const leaseAlias = `leasebase${stamp}`, leaseId = `n_leasebase_${stamp}`;
991
+ let leaseTok: Tok;
992
+ beforeAll(() => { seedNode(leaseId,leaseAlias,NET,ownerId);seedSession(leaseId,leaseAlias,NET);leaseTok=mint(ownerId,NET,leaseAlias,2,leaseId); });
993
+
994
+ function activityTask(nodeId: string, alias: string, network = NET) {
995
+ const id = `task_activity_${crypto.randomUUID()}`;
996
+ db.run("INSERT INTO tasks (task_id, from_name, to_node_id, to_name, status, content, network_id) VALUES (?1, 'scheduler', ?2, ?3, 'running', 'activity fixture', ?4)", [id, nodeId, alias, network]);
997
+ return { task_id: id, thread_id: `thread_${id}`, turn_id: `turn_${id}` };
998
+ }
999
+ const activitySnap = (id: string) => db.get<any>("SELECT status, consumed_at, runtime_submitted_at, started_at, thread_id, turn_id, last_activity_at FROM tasks WHERE task_id = ?1", id);
1000
+ async function consumeActivityTask(context: {task_id: string; thread_id: string; turn_id: string}) {
1001
+ expect((await tool(leaseTok.token, "mark_tasks_consumed", { task_ids: [context.task_id], task_contexts: [context] })).ok).toBe(true);
1002
+ }
1003
+ const renewActivity = (token: string, contexts: Array<{task_id: string; thread_id: string; turn_id: string}>) => tool(token, "mark_tasks_consumed", { task_ids: contexts.map(c => c.task_id), task_contexts: contexts, activity_only: true });
1004
+
1005
+ test("activity lease: exact task renewal changes only the activity clock; duplicate consumption does not renew", async () => {
1006
+ const c = activityTask(leaseId, leaseAlias); await consumeActivityTask(c);
1007
+ db.run("UPDATE tasks SET last_activity_at = datetime('now', '-2 hours') WHERE task_id = ?1", [c.task_id]);
1008
+ const before = activitySnap(c.task_id);
1009
+ expect((await tool(leaseTok.token, "mark_tasks_consumed", { task_ids: [c.task_id], task_contexts: [c] })).ok).toBe(true);
1010
+ expect(activitySnap(c.task_id)).toEqual(before);
1011
+ const out = await renewActivity(leaseTok.token, [c]); expect(out.ok).toBe(true);
1012
+ const after = activitySnap(c.task_id);
1013
+ expect(after.last_activity_at).not.toBe(before.last_activity_at);
1014
+ expect({...after, last_activity_at: before.last_activity_at}).toEqual(before);
1015
+ expect(out.tasks[0].last_activity_at).toBe(after.last_activity_at);
1016
+ });
1017
+
1018
+ test("activity lease: bound node wins over a same-name session and cannot renew the other node", async () => {
1019
+ const alias = `leasebound${stamp}`, mine = `n_leasebound_${stamp}`, other = `n_leaseother_${stamp}`;
1020
+ seedNode(mine, alias, NET, ownerId); seedSession(mine, alias, NET);
1021
+ const tok = mint(ownerId, NET, leaseAlias, 2, mine), own = activityTask(mine, alias);
1022
+ expect((await tool(tok.token, "mark_tasks_consumed", {task_ids: [own.task_id], task_contexts: [own]})).ok).toBe(true);
1023
+ seedNode(other, alias, NET, ownerId); db.run("UPDATE sessions SET node_id=?1 WHERE alias=?2 AND network_id=?3",[other,alias,NET]);
1024
+ expect((await renewActivity(tok.token, [own])).ok).toBe(true);
1025
+ const foreign = activityTask(other, alias);
1026
+ db.run("UPDATE tasks SET consumed_at=datetime('now'),thread_id=?1,turn_id=?2 WHERE task_id=?3", [foreign.thread_id,foreign.turn_id,foreign.task_id]);
1027
+ expect((await renewActivity(tok.token,[foreign])).error).toBe("task_not_owned");
1028
+ expect(activitySnap(foreign.task_id).last_activity_at).toBeNull();
1029
+ });
1030
+
1031
+ for (const shape of ["wrong thread", "wrong turn", "foreign node", "foreign network", "legacy null node", "not consumed", "missing context", "user token"] as const) {
1032
+ test(`activity lease: rejects ${shape} without changing the task`, async () => {
1033
+ const c = activityTask(leaseId, leaseAlias); if(shape !== "not consumed") await consumeActivityTask(c);
1034
+ if(shape === "foreign node") db.run("UPDATE tasks SET to_node_id=?1 WHERE task_id=?2", [otherId,c.task_id]);
1035
+ if(shape === "foreign network") db.run("UPDATE tasks SET network_id=?1 WHERE task_id=?2", [NET2,c.task_id]);
1036
+ if(shape === "legacy null node") db.run("UPDATE tasks SET to_node_id=NULL WHERE task_id=?1",[c.task_id]);
1037
+ const before=activitySnap(c.task_id); const context={...c};
1038
+ if(shape === "wrong thread") context.thread_id="sibling_thread";
1039
+ if(shape === "wrong turn") context.turn_id="sibling_turn";
1040
+ const out=await tool(shape === "user token" ? ownerToken : leaseTok.token,"mark_tasks_consumed",{task_ids:[c.task_id],task_contexts:shape === "missing context" ? [] : [context],activity_only:true});
1041
+ expect(out.ok).toBe(false); expect(activitySnap(c.task_id)).toEqual(before);
1042
+ });
1043
+ }
1044
+ for(const status of ["replied","failed","cancelled","expired"]){
1045
+ test(`activity lease: ${status} terminal never receives activity or reopens`,async()=>{
1046
+ const c=activityTask(leaseId,leaseAlias);await consumeActivityTask(c);db.run("UPDATE tasks SET status=?1 WHERE task_id=?2",[status,c.task_id]);const before=activitySnap(c.task_id);
1047
+ expect((await renewActivity(leaseTok.token,[c])).error).toBe("task_activity_terminal");expect(activitySnap(c.task_id)).toEqual(before);
1048
+ });
1049
+ }
1050
+ test("activity lease: conflicting batch is rejected with zero partial writes",async()=>{
1051
+ const first=activityTask(leaseId,leaseAlias),second=activityTask(leaseId,leaseAlias);await consumeActivityTask(first);await consumeActivityTask(second);
1052
+ const before=activitySnap(first.task_id),before2=activitySnap(second.task_id);
1053
+ expect((await renewActivity(leaseTok.token,[first,{...second,turn_id:"old_turn"}])).ok).toBe(false);
1054
+ expect(activitySnap(first.task_id)).toEqual(before);expect(activitySnap(second.task_id)).toEqual(before2);
1055
+ });
1056
+
1057
+ test("activity lease: injected second-write failure rolls back the first write on the real backend",async()=>{
1058
+ const first=activityTask(leaseId,leaseAlias),second=activityTask(leaseId,leaseAlias);await consumeActivityTask(first);await consumeActivityTask(second);
1059
+ const trigger=`activity_fail_${stamp}`,fn=`activity_fn_${stamp}`;
1060
+ if(db.dialect === "postgres")db.exec(`CREATE FUNCTION ${fn}() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.task_id='${second.task_id}' THEN RAISE EXCEPTION 'test1113_injected_activity_failure'; END IF; RETURN NEW; END; $$; CREATE TRIGGER ${trigger} BEFORE UPDATE OF last_activity_at ON tasks FOR EACH ROW EXECUTE FUNCTION ${fn}()`);
1061
+ else db.exec(`CREATE TRIGGER ${trigger} BEFORE UPDATE OF last_activity_at ON tasks WHEN NEW.task_id='${second.task_id}' BEGIN SELECT RAISE(ABORT,'test1113_injected_activity_failure'); END`);
1062
+ const before=activitySnap(first.task_id),before2=activitySnap(second.task_id);let failed=false;
1063
+ try{await renewActivity(leaseTok.token,[first,second]);}catch(e){failed=String(e).includes("test1113_injected_activity_failure");}
1064
+ finally{if(db.dialect === "postgres")db.exec(`DROP TRIGGER ${trigger} ON tasks; DROP FUNCTION ${fn}()`);else db.exec(`DROP TRIGGER ${trigger}`);}
1065
+ expect(failed).toBe(true);expect(activitySnap(first.task_id)).toEqual(before);expect(activitySnap(second.task_id)).toEqual(before2);
1066
+ });
1067
+ test("activity lease: revoked credential cannot report activity",async()=>{
1068
+ const tok=mint(ownerId,NET,leaseAlias,2,leaseId),c=activityTask(leaseId,leaseAlias);await consumeActivityTask(c);const before=activitySnap(c.task_id);
1069
+ db.run("UPDATE api_tokens SET revoked_at=datetime('now') WHERE token_id=?1",[tok.tokenId]);
1070
+ const response=await fetch(`${base}/mcp`,{method:"POST",headers:{Authorization:`Bearer ${tok.token}`,"Content-Type":"application/json",Accept:"application/json, text/event-stream"},body:JSON.stringify({jsonrpc:"2.0",id:1,method:"tools/call",params:{name:"mark_tasks_consumed",arguments:{task_ids:[c.task_id],task_contexts:[c],activity_only:true}}})});
1071
+ expect(response.status).toBe(401);expect(activitySnap(c.task_id)).toEqual(before);
1072
+ });
1073
+
1074
+ test("activity lease: real runtime reporter payload traverses authenticated HTTP into the exact task clock",async()=>{
1075
+ const c=activityTask(leaseId,leaseAlias);let clock=0;const errors:string[]=[];
1076
+ const reporter=createTaskRuntimeEvidenceReporter({taskId:c.task_id,now:()=>clock,
1077
+ report:(_level,id,context)=>tool(leaseTok.token,"mark_tasks_consumed",{task_ids:[id],task_contexts:[{task_id:id,thread_id:context!.threadId,turn_id:context!.turnId}]}),
1078
+ reportActivity:(id,context)=>tool(leaseTok.token,"mark_tasks_consumed",taskActivityPayload(id,context)),debug:message=>errors.push(message)});
1079
+ reporter.consumed({threadId:c.thread_id,turnId:c.turn_id});await until(()=>!!activitySnap(c.task_id).consumed_at,"consumption stamped");
1080
+ const before=activitySnap(c.task_id);clock=30_000;reporter.activity({taskId:"other_task",threadId:c.thread_id,turnId:c.turn_id});await new Promise(resolve=>setTimeout(resolve,5));expect(activitySnap(c.task_id)).toEqual(before);
1081
+ reporter.activity({taskId:c.task_id,threadId:c.thread_id,turnId:c.turn_id});await until(()=>!!activitySnap(c.task_id).last_activity_at,"runtime activity stamped");
1082
+ expect(errors).toEqual([]);expect(activitySnap(c.task_id).consumed_at).toBe(before.consumed_at);expect(activitySnap(c.task_id).status).toBe("running");
1083
+ });
@@ -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,77 @@ 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
+ });
289
+
290
+
291
+ describe("#1113 activity idle clock",()=>{
292
+ test("fresh exact-task activity protects a long consumed turn, silent activity still expires",()=>{
293
+ const s=seedSchedule(),first=fire(s),now=new Date(),at=(m:number)=>new Date(now.getTime()-m*60_000).toISOString();
294
+ db.run("UPDATE tasks SET status='running',created_at=?1,consumed_at=?2,last_activity_at=?3 WHERE task_id=?4",[at(180),at(120),at(1),first.taskId]);
295
+ expect(fire(s,now).status).toBe("skipped");expect(taskRow(first.taskId!).status).toBe("running");
296
+ db.run("UPDATE tasks SET last_activity_at=?1 WHERE task_id=?2",[at(61),first.taskId]);expect(fire(s,now).status).toBe("delivered");
297
+ expect(runRow(first.runId).error_message).toContain("since last_activity_at");
298
+ });
299
+ test("fresh activity cannot evade the absolute total cap",()=>{
300
+ const s=seedSchedule(),first=fire(s),now=new Date(),at=(m:number)=>new Date(now.getTime()-m*60_000).toISOString();
301
+ db.run("UPDATE tasks SET status='running',created_at=?1,consumed_at=?2,last_activity_at=?3 WHERE task_id=?4",[at(1441),at(1440),at(1),first.taskId]);
302
+ expect(fire(s,now).status).toBe("delivered");expect(runRow(first.runId).error_message).toContain("total cap");
303
+ });
304
+ test("activity without consumption, before consumption or in the future never renews",()=>{
305
+ const now=new Date("2026-10-13T04:00:00Z"),schedule=JSON.stringify({type:"interval",every_seconds:60});
306
+ for(const row of [
307
+ {created_at:"2026-10-13 01:00:00",last_activity_at:"2026-10-13 03:59:00"},
308
+ {created_at:"2026-10-13 01:00:00",consumed_at:"2026-10-13 02:00:00",last_activity_at:"2026-10-13 01:59:00"},
309
+ {created_at:"2026-10-13 01:00:00",consumed_at:"2026-10-13 02:00:00",last_activity_at:"2026-10-13 04:01:00"},
310
+ ])expect(blockerTimedOut(row,schedule,now)).toBe(true);
311
+ });
312
+ });
@@ -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,51 @@ 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
+ last_activity_at?: string | null;
63
+ started_at?: string | null;
64
+ };
65
+
66
+ /** #1105: queue time is not execution time. Submission/ACK/node heartbeat are not consumption. */
67
+ export function blockerTimeoutClock(blocker: StuckBlockerClock, scheduleJson: string, now: Date): { ageMs: number; limitMs: number; source: string } {
68
+ const created = parseDbTimestampMs(blocker.created_at);
69
+ const nowMs = now.getTime();
70
+ const floor = envNumber("COMMHUB_SCHEDULE_STUCK_TIMEOUT_FLOOR_SEC", 3_600, 1);
71
+ const capMs = Math.max(floor, envNumber("COMMHUB_SCHEDULE_STUCK_TIMEOUT_CAP_SEC", 86_400, 1)) * 1000;
72
+ // Even a very late consumption cannot hold this occurrence beyond the total cap.
73
+ if (Number.isFinite(created) && nowMs - created >= capMs) {
74
+ return { ageMs: nowMs - created, limitMs: capMs, source: "created_at (total cap)" };
75
+ }
76
+ for (const source of ["consumed_at", "started_at"] as const) {
77
+ const value = blocker[source];
78
+ if (!value) continue;
79
+ const at = parseDbTimestampMs(value);
80
+ if (Number.isFinite(at) && at <= nowMs && (!Number.isFinite(created) || at >= created)) {
81
+ if (source === "consumed_at" && blocker.last_activity_at) {
82
+ const activity = parseDbTimestampMs(blocker.last_activity_at);
83
+ if (Number.isFinite(activity) && activity >= at && activity <= nowMs) {
84
+ return { ageMs: nowMs - activity, limitMs: stuckTimeoutMs(scheduleJson), source: "last_activity_at" };
85
+ }
86
+ }
87
+ return { ageMs: nowMs - at, limitMs: stuckTimeoutMs(scheduleJson), source };
88
+ }
89
+ }
90
+ return { ageMs: nowMs - created, limitMs: stuckTimeoutMs(scheduleJson), source: "created_at" };
91
+ }
58
92
 
59
93
  /** 挡路的任务开了多久已经到超时 → 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);
94
+ export function blockerTimedOut(blocker: StuckBlockerClock, scheduleJson: string, now: Date): boolean {
95
+ const clock = blockerTimeoutClock(blocker, scheduleJson, now);
96
+ return Number.isFinite(clock.ageMs) && clock.ageMs >= clock.limitMs;
64
97
  }
65
98
 
66
99
  /**
67
100
  * 在调用方的事务里按超时结束挡路的任务。只动还开着的任务(条件写),返回是否真的结束了它。
68
101
  * 与 TTL 巡检(server.ts patrolExpiredTasks)同一套写法:status=expired、收件箱行置 acked、镜像到 run。
69
102
  */
70
- export function expireStuckBlocker(taskId: string, networkId: string, ageMs: number, limitMs: number): boolean {
103
+ export function expireStuckBlocker(taskId: string, networkId: string, ageMs: number, limitMs: number, source = "created_at"): boolean {
71
104
  const changed = db.run(
72
105
  `UPDATE tasks SET status = 'expired', completed_at = datetime('now')
73
106
  WHERE task_id = ?1 AND network_id = ?2 AND status IN ('created', 'delivered', 'acked', 'running')`,
@@ -78,7 +111,7 @@ export function expireStuckBlocker(taskId: string, networkId: string, ageMs: num
78
111
  syncScheduledRunForTask(taskId, networkId);
79
112
  db.run(
80
113
  "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],
114
+ [`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
115
  );
83
116
  return true;
84
117
  }
@@ -127,12 +160,12 @@ export function stuckNoticeText(n: StuckNotice): { title: string; text: string }
127
160
  return {
128
161
  title: "定时任务被卡住",
129
162
  text: `定时任务「${n.scheduleName}」被上一次的执行卡住了:任务 ${n.taskId} 于 ${started} 派给节点 ${n.alias},到现在还没有完成,之后的 ${n.skips} 次执行都被跳过。\n\n`
130
- + `它开着超过 ${fmtDuration(n.timeoutMs)} 后会被自动按超时结束,下一次照常派发。等不及可以在任务列表里取消这条任务。`,
163
+ + `距上次任务活动或实际开始执行超过 ${fmtDuration(n.timeoutMs)} 后会被自动按超时结束;尚未开始的任务从派发起计时,所有执行仍受总时限保护。下一次照常派发。等不及可以在任务列表里取消这条任务。`,
131
164
  };
132
165
  }
133
166
  return {
134
167
  title: "定时任务已超时放行",
135
- text: `定时任务「${n.scheduleName}」上一次的任务 ${n.taskId}(${started} 派给节点 ${n.alias})开着超过 ${fmtDuration(n.timeoutMs)} 还没有完成,已按超时结束,不再挡后面的执行。`,
168
+ text: `定时任务「${n.scheduleName}」上一次的任务 ${n.taskId}(${started} 派给节点 ${n.alias})超过 ${fmtDuration(n.timeoutMs)} 的执行或总保护时限还没有完成,已按超时结束,不再挡后面的执行。`,
136
169
  };
137
170
  }
138
171
 
@@ -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; last_activity_at: string | null; status: string }>(
416
+ `SELECT r.task_id, t.created_at, t.consumed_at, t.started_at, t.last_activity_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()) {
@@ -0,0 +1,60 @@
1
+ import { db } from "./db.js";
2
+ import { resolveNodeCaller } from "./create-node.js";
3
+
4
+ type Context = { task_id: string; thread_id: string; turn_id: string };
5
+
6
+ /** Renew only an already-consumed, exact original turn. No alias/content/heartbeat inference. */
7
+ export function markTaskActivity(input: {
8
+ tokenId: string | null | undefined;
9
+ networkId: string | null | undefined;
10
+ nodeToken: boolean;
11
+ identityRejected: boolean;
12
+ taskIds: string[];
13
+ contexts: Context[];
14
+ }) {
15
+ const deny = (error: string, task_id?: string) => ({ ok: false as const, error, ...(task_id ? { task_id } : {}) });
16
+ if (!input.nodeToken || !input.networkId || input.identityRejected) return deny("node_token_required");
17
+ if (db.transactionalFeaturesRefusal !== null) return deny("task_runtime_evidence_backend_unsupported");
18
+ if (new Set(input.taskIds).size !== input.taskIds.length) return deny("duplicate_task_id");
19
+ const contexts = new Map(input.contexts.map((c) => [c.task_id, c]));
20
+ if (contexts.size !== input.contexts.length) return deny("duplicate_task_context");
21
+ const foreign = input.contexts.find((c) => !input.taskIds.includes(c.task_id));
22
+ if (foreign) return deny("task_context_not_requested", foreign.task_id);
23
+ const missing = input.taskIds.find((id) => !contexts.has(id));
24
+ if (missing) return deny("task_activity_context_required", missing);
25
+
26
+ return db.transaction(() => {
27
+ // Resolve inside the transaction: renamed sessions and same-name rows cannot substitute for the token.
28
+ const caller = resolveNodeCaller(input.tokenId);
29
+ if (!caller.ok || caller.kind !== "node" || caller.networkId !== input.networkId) return deny("node_token_required");
30
+ const placeholders = input.taskIds.map((_, i) => `?${i + 2}`).join(", ");
31
+ const rows = db.all<{ task_id: string; to_node_id: string | null; status: string; consumed_at: string | null; thread_id: string | null; turn_id: string | null }>(
32
+ `SELECT task_id, to_node_id, status, consumed_at, thread_id, turn_id FROM tasks
33
+ WHERE network_id = ?1 AND task_id IN (${placeholders})`, input.networkId, ...input.taskIds,
34
+ );
35
+ const byId = new Map(rows.map((row) => [row.task_id, row]));
36
+ for (const id of input.taskIds) {
37
+ const row = byId.get(id);
38
+ if (!row || row.to_node_id !== caller.nodeId) return deny("task_not_owned", id);
39
+ if (!["created", "delivered", "acked", "running"].includes(row.status)) return deny("task_activity_terminal", id);
40
+ if (!row.consumed_at || !row.thread_id || !row.turn_id) return deny("task_activity_not_consumed", id);
41
+ const context = contexts.get(id)!;
42
+ if (row.thread_id !== context.thread_id || row.turn_id !== context.turn_id) return deny("task_runtime_context_conflict", id);
43
+ }
44
+ for (const id of input.taskIds) {
45
+ const context = contexts.get(id)!;
46
+ const changed = db.run(
47
+ `UPDATE tasks SET last_activity_at = datetime('now')
48
+ WHERE task_id = ?1 AND network_id = ?2 AND to_node_id = ?3
49
+ AND thread_id = ?4 AND turn_id = ?5 AND consumed_at IS NOT NULL
50
+ AND status IN ('created', 'delivered', 'acked', 'running')`,
51
+ [id, input.networkId, caller.nodeId, context.thread_id, context.turn_id],
52
+ );
53
+ if (changed.changes !== 1) throw new Error(`task_activity_write_race:${id}`);
54
+ }
55
+ return { ok: true as const, tasks: db.all<{ task_id: string; last_activity_at: string }>(
56
+ `SELECT task_id, last_activity_at FROM tasks WHERE network_id = ?1 AND task_id IN (${placeholders})`,
57
+ input.networkId, ...input.taskIds,
58
+ ) };
59
+ });
60
+ }
package/src/tools.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { markTaskActivity } from "./task-activity.js";
2
3
  import { requestProviderSnapshot, requestProviderWrite, requestProviderCollection, requestProviderApplication, requestProviderLogin, getProviderSnapshot, pullProviderSnapshots, ackProviderSnapshot } from "./daemon-provider-snapshots.js";
3
4
  import { readHubProviders, putHubProvider, bindHubProvider } from './hub-provider-config.js';
4
5
  import { requestScopedSecrets, getScopedSecrets, pullScopedSecrets, ackScopedSecrets, requestNodeSecrets, pullNodeSecrets, ackNodeSecrets } from './scoped-secrets.js';
@@ -1696,9 +1697,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1696
1697
 
1697
1698
  server.tool(
1698
1699
  "mark_tasks_consumed",
1699
- "Internal agent-node signal: exact tasks produced attributable turn-start/activity evidence in this token-bound node's runtime.",
1700
- taskRuntimeEvidenceSchema,
1701
- async ({ task_ids, task_contexts }) => markTaskRuntimeEvidence(task_ids, "consumed", task_contexts),
1700
+ "Internal token-bound task consumption; activity_only reports original-turn activity.",
1701
+ { ...taskRuntimeEvidenceSchema, activity_only: z.boolean().optional() },
1702
+ async ({ task_ids, task_contexts, activity_only }) => activity_only
1703
+ ? { content: [{ type: "text" as const, text: JSON.stringify(markTaskActivity({ tokenId: callerTokenId, networkId: enforceNetworkId, nodeToken: callerTokenIsNetwork, identityRejected: callerIdentityRejected, taskIds: task_ids, contexts: task_contexts ?? [] })) }] }
1704
+ : markTaskRuntimeEvidence(task_ids, "consumed", task_contexts),
1702
1705
  );
1703
1706
 
1704
1707
  // ═══════════════════════════════════════════