@sleep2agi/commhub-server 0.9.0-preview.134 → 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.134",
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
+ });
@@ -286,3 +286,27 @@ describe("#1112 execution clock", () => {
286
286
  expect(blockerTimedOut({ created_at: "2026-10-13 01:00:00", consumed_at: "2026-10-13 02:00:00" }, schedule, now)).toBe(true);
287
287
  });
288
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
+ });
@@ -59,6 +59,7 @@ export function stuckTimeoutMs(scheduleJson: string): number {
59
59
  export type StuckBlockerClock = {
60
60
  created_at: string;
61
61
  consumed_at?: string | null;
62
+ last_activity_at?: string | null;
62
63
  started_at?: string | null;
63
64
  };
64
65
 
@@ -77,6 +78,12 @@ export function blockerTimeoutClock(blocker: StuckBlockerClock, scheduleJson: st
77
78
  if (!value) continue;
78
79
  const at = parseDbTimestampMs(value);
79
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
+ }
80
87
  return { ageMs: nowMs - at, limitMs: stuckTimeoutMs(scheduleJson), source };
81
88
  }
82
89
  }
@@ -153,7 +160,7 @@ export function stuckNoticeText(n: StuckNotice): { title: string; text: string }
153
160
  return {
154
161
  title: "定时任务被卡住",
155
162
  text: `定时任务「${n.scheduleName}」被上一次的执行卡住了:任务 ${n.taskId} 于 ${started} 派给节点 ${n.alias},到现在还没有完成,之后的 ${n.skips} 次执行都被跳过。\n\n`
156
- + `从实际开始执行起超过 ${fmtDuration(n.timeoutMs)} 后会被自动按超时结束;尚未开始的任务从派发起计时,所有执行仍受总时限保护。下一次照常派发。等不及可以在任务列表里取消这条任务。`,
163
+ + `距上次任务活动或实际开始执行超过 ${fmtDuration(n.timeoutMs)} 后会被自动按超时结束;尚未开始的任务从派发起计时,所有执行仍受总时限保护。下一次照常派发。等不及可以在任务列表里取消这条任务。`,
157
164
  };
158
165
  }
159
166
  return {
@@ -412,8 +412,8 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
412
412
  // received:节点那边有没有把这条任务取走过(inbox 行被 ack,或任务已进 acked/running)。
413
413
  // 跳过记录带上它,客户端才分得清「节点还没收到」和「节点在处理」—— 前者是投递故障,
414
414
  // 后者才是真的上一次还没做完。
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,
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,
417
417
  CASE WHEN t.status IN ('acked', 'running')
418
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)
419
419
  THEN 1 ELSE 0 END AS received
@@ -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
  // ═══════════════════════════════════════════