@sleep2agi/commhub-server 0.9.0-preview.60 → 0.9.0-preview.64

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/src/server.ts CHANGED
@@ -16,9 +16,11 @@ import { pendingInboxCount } from "./inbox-count.js";
16
16
  import { addNetworkScope, canRestWriteNetwork, getUserNetworkIds, resolveRestNetworkScope, resolveRestWriteNetworkId, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
17
17
  import { validateAvatarUrl } from "./avatar-validate.js";
18
18
  import { narrowTags, parseStoredTags, validateScalarAttr } from "./node-attrs-validate.js";
19
+ import { daemonDefaultWorkdirRoot } from "./create-node-validate.js";
19
20
  import { register, login, resolveToken, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, issueUserToken, listTokens, createToken, revokeToken, getNetworkMembers, getUserNetworkRole, addNetworkMember, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
20
21
  import { abortRename, cleanupCommittedRenameSessions, commitRename, prepareRename, resolveCanonicalAlias } from "./rename.js";
21
22
  import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
23
+ import { clientRequestIdFromMeta, idempotentTaskId, idempotentTaskMatches, type StoredIdempotentTask } from "./task-idempotency.js";
22
24
  import { getLoginClientIp, sharedLoginFailureLockout, sharedLoginIpRateLimiter } from "./auth_login_guard.js";
23
25
  import {
24
26
  FILE_ID_REGEX,
@@ -53,6 +55,7 @@ import { resolveRestFromSession } from "./rest-identity.js";
53
55
  import { stampTaskAuthOrigin, type TaskAuthOrigin } from "./task-auth-origin.js";
54
56
  import { diagnoseTask } from "./task-diagnostic.js";
55
57
  import { assertScheduledTaskBackendSupported, handleScheduledTaskRequest, startScheduledTaskScheduler } from "./scheduled-tasks.js";
58
+ import { handleRequirementsRequest } from "./requirements.js";
56
59
  import { handleExternalScheduleEditRequest } from "./external-schedule-edits.js";
57
60
  import { recordDeliveredStaleEvents } from "./task-lifecycle-watcher.js";
58
61
  import { SIDE_THREAD_FEATURE_FLAG, SideThreadCoordinator, SideThreadPortRegistry, SideThreadStore, type SideThreadActor, type SideThreadAttachmentRef, type SideThreadExecutionPort } from "./side-thread.js";
@@ -738,6 +741,16 @@ export function patrolExpiredTasks(): void {
738
741
  // in this count. The WHERE clause still targets one exact guarded
739
742
  // task, so any positive count proves the task transition happened.
740
743
  if (result.changes < 1) continue;
744
+ // #2022 — retire the inbox rows in the same transaction. Nothing
745
+ // else acks them: an expired task can never be acked by its target,
746
+ // and retention only sweeps acked=1 rows, so leaving them unacked
747
+ // kept them in every "unacked inbox" consumer (the stop/delete gate,
748
+ // the unread badge) forever. COALESCE(task_id, id) mirrors
749
+ // cancel_task — legacy rows carry the logical task id in `id`.
750
+ db.run(
751
+ `UPDATE inbox SET acked = 1 WHERE COALESCE(task_id, id) = ?1 AND acked = 0`,
752
+ [task.task_id],
753
+ );
741
754
  syncScheduledRunForTask(task.task_id, task.network_id);
742
755
  changed.push(task);
743
756
  }
@@ -1614,6 +1627,16 @@ return Bun.serve({
1614
1627
  });
1615
1628
  if (scheduledResponse) return withCors(req, scheduledResponse);
1616
1629
 
1630
+ const requirementsResponse = await handleRequirementsRequest({
1631
+ req,
1632
+ url,
1633
+ auth: restAuth,
1634
+ isAdmin,
1635
+ isNodeToken: requestToken(req).startsWith("ntok_"),
1636
+ scope: restScope,
1637
+ });
1638
+ if (requirementsResponse) return withCors(req, requirementsResponse);
1639
+
1617
1640
  // ── #473 REST: SSE connection detail (ops-only) ──
1618
1641
  // The per-key breakdown /health used to expose anonymously: keys are
1619
1642
  // `{networkId}:{alias}` (+ `\0netobs:{networkId}` observer keys via
@@ -2391,7 +2414,6 @@ return Bun.serve({
2391
2414
  ...(canonical.renamed ? { renamed_from: body.alias, renamed_to: targetAlias } : {}),
2392
2415
  }, { status: 404 }));
2393
2416
  }
2394
- const id = crypto.randomUUID();
2395
2417
  // Identity binding, mirroring the MCP transport (tools.ts defaultFrom /
2396
2418
  // fromIdentityMismatchReply). Both transports resolve the same
2397
2419
  // api_tokens row through resolveToken, and auth.ts already documents the
@@ -2450,12 +2472,61 @@ return Bun.serve({
2450
2472
  : "legacy";
2451
2473
  const metaJson = normalizeMetaJson(stampTaskAuthOrigin(mergedMeta, authOrigin));
2452
2474
 
2475
+ // Client-request-id idempotency, mirroring the MCP `send_task` tool
2476
+ // (tools.ts). The app/Dashboard mint one `meta.client_request_id` per
2477
+ // message bubble and reuse it only to retry that bubble, so:
2478
+ // - the task id is derived from (network, from, request id): a retry
2479
+ // after a lost response finds the row it already wrote and gets the
2480
+ // same task id back (200, idempotent_replay) — across restarts too;
2481
+ // - the same id with a different payload fails closed (409);
2482
+ // - a NEW id is a new message even when the text is identical, so it
2483
+ // must not hit the #212 content-hash window below (that silently
2484
+ // dropped a user's second 「好」 while the app showed it delivered).
2485
+ const clientRequestId = clientRequestIdFromMeta(mergedMeta);
2486
+ const id = clientRequestId
2487
+ ? idempotentTaskId(taskNetId ?? null, fromSession, clientRequestId)
2488
+ : crypto.randomUUID();
2489
+ if (clientRequestId) {
2490
+ const existing = db.get<StoredIdempotentTask & { to_node_id: string | null }>(
2491
+ "SELECT task_id, from_name, to_node_id, to_name, priority, content, network_id, meta_json, status FROM tasks WHERE task_id = ?1",
2492
+ [id],
2493
+ );
2494
+ if (existing) {
2495
+ if (!idempotentTaskMatches(existing, {
2496
+ fromName: fromSession, toName: targetAlias, priority: body.priority, content: body.task,
2497
+ networkId: taskNetId ?? null, metaJson,
2498
+ })) {
2499
+ console.log(`[/api/task] ${fromSession} → ${targetAlias}: REJECTED idempotency_conflict (key=request_id, task=${existing.task_id.slice(0, 13)})`);
2500
+ return withCors(req, Response.json({
2501
+ ok: false,
2502
+ error: "idempotency_conflict",
2503
+ message: "client_request_id was already used with a different task payload",
2504
+ }, { status: 409 }));
2505
+ }
2506
+ console.log(`[/api/task] ${fromSession} → ${targetAlias}: REPLAY (key=request_id, task=${existing.task_id.slice(0, 13)})`);
2507
+ return withCors(req, Response.json({
2508
+ ok: true,
2509
+ task_id: existing.task_id,
2510
+ message_id: existing.task_id,
2511
+ task_status: existing.status,
2512
+ idempotent_replay: true,
2513
+ actual_to: {
2514
+ alias: existing.to_name,
2515
+ to_node_id: existing.to_node_id ?? null,
2516
+ network_id: existing.network_id ?? null,
2517
+ },
2518
+ }));
2519
+ }
2520
+ }
2521
+
2453
2522
  // #212 dedup guardrail. Mirrors the MCP `send_task` tool: same
2454
2523
  // (from_session, target_alias, task) within COMMHUB_SEND_DEDUP_WINDOW_MS
2455
2524
  // is rejected with a structured `duplicate_send` error so dashboard
2456
2525
  // dispatch buttons and scripted REST callers get the same guarantee
2457
- // as agent-driven MCP traffic.
2458
- const dedup = sharedSendDedup.check(fromSession, targetAlias, body.task);
2526
+ // as agent-driven MCP traffic. Sends carrying a client_request_id are
2527
+ // keyed on that id instead of the content (send_dedup.ts header).
2528
+ const dedupScope = { requestId: clientRequestId, networkId: taskNetId ?? null };
2529
+ const dedup = sharedSendDedup.check(fromSession, targetAlias, body.task, undefined, dedupScope);
2459
2530
  if (dedup.duplicate) {
2460
2531
  const payload = buildDuplicateSendPayload({
2461
2532
  from: fromSession,
@@ -2463,7 +2534,7 @@ return Bun.serve({
2463
2534
  ageMs: dedup.ageMs,
2464
2535
  windowMs: sharedSendDedup.windowMs,
2465
2536
  });
2466
- console.log(`[/api/task] ${fromSession} → ${targetAlias}: DROPPED duplicate (age=${dedup.ageMs}ms, window=${sharedSendDedup.windowMs}ms)`);
2537
+ console.log(`[/api/task] ${fromSession} → ${targetAlias}: DROPPED duplicate (key=${dedup.kind}, age=${dedup.ageMs}ms, window=${sharedSendDedup.windowMs}ms)`);
2467
2538
  return withCors(req, Response.json(payload, { status: 429 }));
2468
2539
  }
2469
2540
 
@@ -2518,7 +2589,7 @@ return Bun.serve({
2518
2589
  // #212 — stamp the dedup index only after the inbox/tasks insert
2519
2590
  // succeeds. Mirrors the MCP `send_task` path so a failed write
2520
2591
  // never shadows a legitimate retry.
2521
- sharedSendDedup.record(fromSession, targetAlias, body.task);
2592
+ sharedSendDedup.record(fromSession, targetAlias, body.task, undefined, dedupScope);
2522
2593
  if (target.state === "offline") {
2523
2594
  return withCors(req, Response.json({
2524
2595
  ok: false,
@@ -3360,6 +3431,13 @@ return Bun.serve({
3360
3431
  out.create_capability_observed_ms_ago = observedMsAgo;
3361
3432
  }
3362
3433
  }
3434
+ // app「新建节点」确认页的默认工作目录根。出现即表示该 daemon 认 node_spec.workdir;
3435
+ // 缺席(老 daemon / 老 hub 的 zod 剥掉了它)时 app 藏起那一行、不发 workdir。
3436
+ // 🔴 它是那台机器的家目录路径 —— 与 ip_internal 同级,只给 admin/owner。
3437
+ if (isPrivileged) {
3438
+ const workdirRoot = daemonDefaultWorkdirRoot(r.config_snapshot);
3439
+ if (workdirRoot) out.default_workdir_root = workdirRoot;
3440
+ }
3363
3441
  return out;
3364
3442
  });
3365
3443
  return withCors(req, Response.json({ ok: true, daemons, count: daemons.length }));
@@ -2,6 +2,7 @@ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { db } from "./db.js";
3
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
4
  import { registerTools } from "./tools.js";
5
+ import { patrolExpiredTasks } from "./server.js";
5
6
 
6
7
  // RFC-027 PR1 — handler-driven tests for stop_node, delete_node,
7
8
  // get_stop_request, ack_stop_request. Drives the MCP handlers via
@@ -42,6 +43,9 @@ function cleanup() {
42
43
  // PR1.2a restart_node tests leave node_config_updates rows; clear
43
44
  // so the next test's restart_node doesn't see them as in_flight.
44
45
  try { db.run("DELETE FROM node_config_updates WHERE network_id = ?1", [n]); } catch {}
46
+ // #2022 seeds tasks rows whose status drives the in-flight join; clear
47
+ // them by network or a later test inherits a stale terminal row.
48
+ try { db.run("DELETE FROM tasks WHERE network_id = ?1", [n]); } catch {}
45
49
  try { db.run("DELETE FROM nodes WHERE network_id = ?1", [n]); } catch {}
46
50
  try { db.run("DELETE FROM sessions WHERE network_id = ?1", [n]); } catch {}
47
51
  try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [n]); } catch {}
@@ -950,3 +954,211 @@ describe("delete_node — stale-deleting redispatch exit (#1286)", () => {
950
954
  expect(r.ok).toBe(true);
951
955
  });
952
956
  });
957
+
958
+ // ── #2022 — in-flight gate: terminal / aged rows must not pin busy ──
959
+ //
960
+ // The old gate was COUNT(*) FROM inbox WHERE session_name=? AND acked=0:
961
+ // no join to tasks, no age limit, and nothing acks an inbox row whose
962
+ // task already died (retention sweeps only acked=1 rows), so a single
963
+ // dead row pinned the node to node_busy_in_flight forever.
964
+ //
965
+ // Rule under test (#2029 review):
966
+ // 1. type='task' rows that join to a tasks row are judged by status
967
+ // ALONE — terminal (replied / failed / cancelled / expired) is not
968
+ // counted, anything else is counted with NO age ceiling. agent-node
969
+ // acks the inbox row only when the task is done, so a 90-minute
970
+ // (or ttl_seconds>3600) run stays acked=0 the whole time and must
971
+ // keep the gate closed; expiry is patrolExpiredTasks' job.
972
+ // Join key is COALESCE(task_id, id), the logical id cancel_task
973
+ // uses.
974
+ // 2. rows with no status of their own — task rows whose tasks row is
975
+ // gone, and non-task rows (reply / broadcast / message) — get the
976
+ // 60-minute age ceiling; nothing else will ever retire them.
977
+ // 3. patrolExpiredTasks acks the matching inbox rows in the same
978
+ // transaction that expires the task.
979
+
980
+ function seedTaskWithInbox(
981
+ net: string,
982
+ alias: string,
983
+ opts: {
984
+ task_id: string;
985
+ status: string;
986
+ /** signed datetime offset for the tasks row, e.g. '-3 days' */
987
+ task_age?: string;
988
+ /** signed datetime offset for the inbox row, e.g. '-3 days' */
989
+ inbox_age?: string;
990
+ /** signed datetime offset for tasks.expires_at, e.g. '+1 hour' */
991
+ expires_in?: string;
992
+ },
993
+ ) {
994
+ db.run(
995
+ `INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response,
996
+ created_at, delivered_at, expires_at, network_id)
997
+ VALUES (?1, 'caller', ?2, 'normal', ?3, 'do the thing', 'reply',
998
+ datetime('now', ?4), datetime('now', ?4), datetime('now', ?5), ?6)`,
999
+ [opts.task_id, alias, opts.status, opts.task_age ?? "0 seconds", opts.expires_in ?? "+1 hour", net],
1000
+ );
1001
+ db.run(
1002
+ `INSERT INTO inbox (id, task_id, session_name, type, priority, content, from_session, network_id, acked, created_at)
1003
+ VALUES (?1, ?1, ?2, 'task', 'normal', 'do the thing', 'caller', ?3, 0, datetime('now', ?4))`,
1004
+ [opts.task_id, alias, net, opts.inbox_age ?? "0 seconds"],
1005
+ );
1006
+ }
1007
+
1008
+ function stopArgs() {
1009
+ return { child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A };
1010
+ }
1011
+
1012
+ describe("#2022 stop gate — terminal task rows are not in flight", () => {
1013
+ test("28-day-old expired + 3-day-old replied rows → stop dispatches, in_flight=0", async () => {
1014
+ setupAlphaNetwork();
1015
+ seedTaskWithInbox(NET_A, CHILD_A_ALIAS, {
1016
+ task_id: "task_2022_expired", status: "expired",
1017
+ task_age: "-28 days", inbox_age: "-28 days", expires_in: "-27 days",
1018
+ });
1019
+ seedTaskWithInbox(NET_A, CHILD_A_ALIAS, {
1020
+ task_id: "task_2022_replied", status: "replied",
1021
+ task_age: "-3 days", inbox_age: "-3 days", expires_in: "-2 days",
1022
+ });
1023
+ const r = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
1024
+ expect(r.ok).toBe(true);
1025
+ expect(r.in_flight_at_dispatch).toBe(0);
1026
+ expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("stopping");
1027
+ });
1028
+
1029
+ test("FRESH terminal row (replied, 0s old) also not counted — terminal filter alone is enough", async () => {
1030
+ setupAlphaNetwork();
1031
+ seedTaskWithInbox(NET_A, CHILD_A_ALIAS, { task_id: "task_2022_fresh_replied", status: "replied" });
1032
+ const r = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
1033
+ expect(r.ok).toBe(true);
1034
+ expect(r.in_flight_at_dispatch).toBe(0);
1035
+ });
1036
+ });
1037
+
1038
+ describe("#2022 stop gate — genuinely in-flight rows still block", () => {
1039
+ test("fresh non-terminal task row → node_busy_in_flight, count surfaced, state untouched", async () => {
1040
+ setupAlphaNetwork();
1041
+ seedTaskWithInbox(NET_A, CHILD_A_ALIAS, { task_id: "task_2022_live", status: "delivered" });
1042
+ const r = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
1043
+ expect(r.ok).toBe(false);
1044
+ expect(r.error).toBe("node_busy_in_flight");
1045
+ expect(r.in_flight_count).toBe(1);
1046
+ expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active");
1047
+ expect(readAudit("stop_node_dispatched", NET_A).length).toBe(0);
1048
+ });
1049
+
1050
+ test("fresh non-task inbox row (type='reply') still counts — judged by age only", async () => {
1051
+ setupAlphaNetwork();
1052
+ db.run(
1053
+ `INSERT INTO inbox (id, session_name, type, content, from_session, network_id, acked, created_at)
1054
+ VALUES (?1, ?2, 'reply', 'fresh reply', 'caller', ?3, 0, datetime('now'))`,
1055
+ [`inbox_2022_fresh_reply`, CHILD_A_ALIAS, NET_A],
1056
+ );
1057
+ const r = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
1058
+ expect(r.ok).toBe(false);
1059
+ expect(r.error).toBe("node_busy_in_flight");
1060
+ expect(r.in_flight_count).toBe(1);
1061
+ });
1062
+ });
1063
+
1064
+ describe("#2022 stop gate — a live task is never aged out (status wins over age)", () => {
1065
+ test("🔴 created 2 hours ago + task.status='running' → still node_busy_in_flight", async () => {
1066
+ setupAlphaNetwork();
1067
+ seedTaskWithInbox(NET_A, CHILD_A_ALIAS, {
1068
+ task_id: "task_2022_running_90m", status: "running",
1069
+ task_age: "-120 minutes", inbox_age: "-120 minutes", expires_in: "-60 minutes",
1070
+ });
1071
+ const r = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
1072
+ expect(r.ok).toBe(false);
1073
+ expect(r.error).toBe("node_busy_in_flight");
1074
+ expect(r.in_flight_count).toBe(1);
1075
+ expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active");
1076
+ });
1077
+
1078
+ test("task.status='acked' 2 hours old (patrol never expires it) → still blocked", async () => {
1079
+ setupAlphaNetwork();
1080
+ seedTaskWithInbox(NET_A, CHILD_A_ALIAS, {
1081
+ task_id: "task_2022_acked_120m", status: "acked",
1082
+ inbox_age: "-120 minutes", expires_in: "+12 hours",
1083
+ });
1084
+ const r = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
1085
+ expect(r.error).toBe("node_busy_in_flight");
1086
+ expect(r.in_flight_count).toBe(1);
1087
+ });
1088
+ });
1089
+
1090
+ describe("#2022 stop gate — age ceiling (60 min) for rows with no status", () => {
1091
+ test("orphan task row (tasks row reaped) 8 days old → not counted", async () => {
1092
+ setupAlphaNetwork();
1093
+ db.run(
1094
+ `INSERT INTO inbox (id, task_id, session_name, type, content, from_session, network_id, acked, created_at)
1095
+ VALUES (?1, ?1, ?2, 'task', 'orphan task', 'caller', ?3, 0, datetime('now', '-8 days'))`,
1096
+ [`task_2022_reaped`, CHILD_A_ALIAS, NET_A],
1097
+ );
1098
+ const r = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
1099
+ expect(r.ok).toBe(true);
1100
+ expect(r.in_flight_at_dispatch).toBe(0);
1101
+ });
1102
+
1103
+ test("non-task row older than the ceiling → not counted (the production backlog shape)", async () => {
1104
+ setupAlphaNetwork();
1105
+ db.run(
1106
+ `INSERT INTO inbox (id, session_name, type, content, from_session, network_id, acked, created_at)
1107
+ VALUES (?1, ?2, 'broadcast', 'week-old broadcast', 'caller', ?3, 0, datetime('now', '-7 days'))`,
1108
+ [`inbox_2022_old_broadcast`, CHILD_A_ALIAS, NET_A],
1109
+ );
1110
+ const r = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
1111
+ expect(r.ok).toBe(true);
1112
+ expect(r.in_flight_at_dispatch).toBe(0);
1113
+ });
1114
+ });
1115
+
1116
+ describe("#2022 patrolExpiredTasks — expires the task AND acks its inbox rows", () => {
1117
+ test("same transaction: tasks.status='expired' + inbox.acked=1 → gate unblocks", async () => {
1118
+ setupAlphaNetwork();
1119
+ seedTaskWithInbox(NET_A, CHILD_A_ALIAS, {
1120
+ task_id: "task_2022_due", status: "delivered",
1121
+ inbox_age: "-10 minutes", expires_in: "-1 minute",
1122
+ });
1123
+
1124
+ // Before the patrol: fresh, non-terminal → the node is correctly busy.
1125
+ const before = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
1126
+ expect(before.error).toBe("node_busy_in_flight");
1127
+ expect(before.in_flight_count).toBe(1);
1128
+
1129
+ patrolExpiredTasks();
1130
+
1131
+ expect(db.get<{ status: string }>("SELECT status FROM tasks WHERE task_id = ?1", "task_2022_due")?.status).toBe("expired");
1132
+ const inboxRow = db.get<{ acked: number }>(
1133
+ "SELECT acked FROM inbox WHERE task_id = ?1 AND session_name = ?2", "task_2022_due", CHILD_A_ALIAS,
1134
+ );
1135
+ expect(inboxRow?.acked).toBe(1);
1136
+
1137
+ // And the gate that was blocked a moment ago now dispatches.
1138
+ const after = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
1139
+ expect(after.ok).toBe(true);
1140
+ expect(after.in_flight_at_dispatch).toBe(0);
1141
+ });
1142
+
1143
+ test("legacy inbox row keyed by id (task_id IS NULL) is acked too", async () => {
1144
+ setupAlphaNetwork();
1145
+ db.run(
1146
+ `INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response,
1147
+ created_at, expires_at, network_id)
1148
+ VALUES ('task_2022_legacy', 'caller', ?1, 'normal', 'delivered', 'do the thing', 'reply',
1149
+ datetime('now', '-10 minutes'), datetime('now', '-1 minute'), ?2)`,
1150
+ [CHILD_A_ALIAS, NET_A],
1151
+ );
1152
+ // Pre-task_id shape: the transport row IS the logical id.
1153
+ db.run(
1154
+ `INSERT INTO inbox (id, session_name, type, content, from_session, network_id, acked, created_at)
1155
+ VALUES ('task_2022_legacy', ?1, 'task', 'do the thing', 'caller', ?2, 0, datetime('now', '-10 minutes'))`,
1156
+ [CHILD_A_ALIAS, NET_A],
1157
+ );
1158
+
1159
+ patrolExpiredTasks();
1160
+
1161
+ expect(db.get<{ status: string }>("SELECT status FROM tasks WHERE task_id = 'task_2022_legacy'")?.status).toBe("expired");
1162
+ expect(db.get<{ acked: number }>("SELECT acked FROM inbox WHERE id = 'task_2022_legacy'")?.acked).toBe(1);
1163
+ });
1164
+ });
package/src/tools.ts CHANGED
@@ -15,6 +15,8 @@ import {
15
15
  validateRuntime,
16
16
  validateModel,
17
17
  validateChannelsP1,
18
+ validateWorkdir,
19
+ daemonDefaultWorkdirRoot,
18
20
  validateEnvRefs,
19
21
  FLAG_KEYS,
20
22
  validateFlagValue,
@@ -712,6 +714,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
712
714
  // 不上报这一格 ≠ 0。旧 daemon 压根不发,读的人必须能把
713
715
  // 「刚测的」「很久以前测的」「不知道」分成三件事说。
714
716
  create_capability_observed_ms_ago: z.number().nullable().catch(null).optional(),
717
+ // app「新建节点」工作目录:daemon 的默认根。出现即表示该 daemon 认 node_spec.workdir。
718
+ // `.catch(undefined)`:同上一格的立场 —— 一个诊断/展示字段不许拒掉整份 report。
719
+ // 读取侧再按形状消毒(create-node-validate.ts daemonDefaultWorkdirRoot)。
720
+ default_workdir_root: z.string().max(1024).optional().catch(undefined),
715
721
  }).optional(),
716
722
  }).optional().describe("RFC-024 — masked node config snapshot"),
717
723
  // app#225 follow-up — the reporting process answers the `rules_file`
@@ -1621,6 +1627,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1621
1627
  message: "client_request_id was already used with a different task payload",
1622
1628
  }) }] };
1623
1629
  }
1630
+ console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: REPLAY (key=request_id, task=${existing.task_id.slice(0, 13)})`);
1624
1631
  return { content: [{ type: "text" as const, text: JSON.stringify({
1625
1632
  ok: true, message_id: existing.task_id, task_id: existing.task_id,
1626
1633
  task_status: existing.status, idempotent_replay: true,
@@ -1644,7 +1651,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1644
1651
  // incident: 50+ identical dispatches across 5 LLM turns ignored
1645
1652
  // three STOP replies — the LLM cannot be trusted to debounce
1646
1653
  // itself, so the runtime layer must.
1647
- const dedup = sharedSendDedup.check(from_session, targetAlias, task);
1654
+ //
1655
+ // A send carrying a validated client_request_id is keyed on that id
1656
+ // instead of the content hash (send_dedup.ts header): a human typing
1657
+ // the same short text twice sent two messages. Same-id retries were
1658
+ // already answered from the durable row above.
1659
+ const dedupScope = { requestId: clientRequestId, networkId: effectiveNetId ?? null };
1660
+ const dedup = sharedSendDedup.check(from_session, targetAlias, task, undefined, dedupScope);
1648
1661
  if (dedup.duplicate) {
1649
1662
  const payload = buildDuplicateSendPayload({
1650
1663
  from: from_session,
@@ -1652,7 +1665,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1652
1665
  ageMs: dedup.ageMs,
1653
1666
  windowMs: sharedSendDedup.windowMs,
1654
1667
  });
1655
- console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: DROPPED duplicate (age=${dedup.ageMs}ms, window=${sharedSendDedup.windowMs}ms)`);
1668
+ console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: DROPPED duplicate (key=${dedup.kind}, age=${dedup.ageMs}ms, window=${sharedSendDedup.windowMs}ms)`);
1656
1669
  return { content: [{ type: "text" as const, text: JSON.stringify(payload) }] };
1657
1670
  }
1658
1671
 
@@ -1692,7 +1705,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1692
1705
  // Only stamp the dedup index after the inbox/tasks transaction
1693
1706
  // succeeds, so a failed insert never silently shadows a legitimate
1694
1707
  // retry.
1695
- sharedSendDedup.record(from_session, targetAlias, task);
1708
+ sharedSendDedup.record(from_session, targetAlias, task, undefined, dedupScope);
1696
1709
 
1697
1710
  // SSE push by alias.
1698
1711
  // The SSE channel is keyed by alias (subscribers connected to /events/<alias>),
@@ -3733,6 +3746,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3733
3746
  telemetry.mem_gb = r.session_mem_total_gb ?? null;
3734
3747
  telemetry.ip_internal = r.session_ip ?? null;
3735
3748
  }
3749
+ // 工作目录默认根会暴露那台机器的家目录路径 —— 与 IP 同级,只给 admin/owner
3750
+ // (也只有他们能 create_node)。见 create-node-validate.ts daemonDefaultWorkdirRoot。
3751
+ const workdirRoot = isPrivileged ? daemonDefaultWorkdirRoot(r.config_snapshot) : null;
3736
3752
  return {
3737
3753
  daemon_node_id: r.node_id,
3738
3754
  alias: r.alias,
@@ -3742,6 +3758,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3742
3758
  runtimes_supported: runtimes,
3743
3759
  allowed_secret_keys: secrets,
3744
3760
  host_telemetry: telemetry,
3761
+ ...(workdirRoot ? { default_workdir_root: workdirRoot } : {}),
3745
3762
  };
3746
3763
  });
3747
3764
 
@@ -3764,6 +3781,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3764
3781
  flags: z.record(z.string(), z.unknown()).optional(),
3765
3782
  env_refs: z.array(z.string().max(64)).optional(),
3766
3783
  channels: z.array(z.unknown()).optional(),
3784
+ // app「新建节点」确认页的工作目录(绝对路径或 ~/…,daemon 侧展开并校验)。
3785
+ // 缺席 = 老行为:落 daemon 的 cwd。
3786
+ workdir: z.string().max(1024).optional().nullable(),
3767
3787
  }),
3768
3788
  network_id: z.string().max(200).optional(),
3769
3789
  },
@@ -3908,6 +3928,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3908
3928
  validateRuntime(node_spec.runtime);
3909
3929
  validateModel(node_spec.model);
3910
3930
  validateChannelsP1((node_spec as any).channels);
3931
+ validateWorkdir(node_spec.workdir);
3911
3932
  for (const [k, v] of Object.entries(node_spec.flags || {})) {
3912
3933
  if (!(FLAG_KEYS as readonly string[]).includes(k)) throw new ValidationError("flag_key_unknown", { field: k });
3913
3934
  validateFlagValue(k, v);
@@ -3915,6 +3936,18 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3915
3936
  } catch (e) {
3916
3937
  return validationFailReply(e);
3917
3938
  }
3939
+ // 🔴 老 daemon 会**静默忽略** node_spec.workdir,把节点建在它自己的 cwd 里 ——
3940
+ // 用户以为指定了目录,实际落到了别处(常常就是 $HOME)。所以没自报支持的 daemon
3941
+ // 直接拒,而不是派一个会被悄悄改写的请求。app 在这种 daemon 上本就不显示那一行。
3942
+ const requestedWorkdir = typeof node_spec.workdir === "string" ? node_spec.workdir.trim() : null;
3943
+ if (requestedWorkdir && !daemonDefaultWorkdirRoot(daemon.config_snapshot)) {
3944
+ return { content: [{ type: "text" as const, text: JSON.stringify({
3945
+ ok: false,
3946
+ error: "workdir_not_supported_by_daemon",
3947
+ hint: "this daemon's agent-node predates create-node workdir support; upgrade it, or omit workdir to use its current directory",
3948
+ daemon_node_id,
3949
+ }) }] };
3950
+ }
3918
3951
 
3919
3952
  // P1 daemon-side allowlist: if daemon publishes allowed_runtimes,
3920
3953
  // enforce at hub for fast-fail; daemon repeats.
@@ -4000,12 +4033,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4000
4033
  const envKeys = Object.keys(envBlob);
4001
4034
  db.run(
4002
4035
  `INSERT INTO node_create_requests
4003
- (request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token)
4004
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, ?10, ?11)`,
4036
+ (request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token, workdir)
4037
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, ?10, ?11, ?12)`,
4005
4038
  [
4006
4039
  requestId, daemon_node_id, node_spec.name, networkIdForChild,
4007
4040
  node_spec.runtime, node_spec.model ?? null, JSON.stringify(node_spec.flags || {}),
4008
4041
  JSON.stringify(envKeys), childTokenId, Date.now(), callerTokenId || "unknown",
4042
+ requestedWorkdir,
4009
4043
  ],
4010
4044
  );
4011
4045
 
@@ -4028,6 +4062,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4028
4062
  model: node_spec.model ?? null,
4029
4063
  flag_keys: Object.keys(node_spec.flags || {}),
4030
4064
  env_keys: envKeys,
4065
+ workdir: requestedWorkdir,
4031
4066
  },
4032
4067
  });
4033
4068
 
@@ -4161,8 +4196,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4161
4196
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "env_blob_unavailable" }) }] };
4162
4197
  }
4163
4198
  // Hydrate spec from row.
4164
- const specRow = db.get<{ child_name: string; runtime: string; model: string | null; flags_json: string }>(
4165
- `SELECT child_name, runtime, model, flags_json FROM node_create_requests WHERE request_id = ?1`,
4199
+ const specRow = db.get<{ child_name: string; runtime: string; model: string | null; flags_json: string; workdir: string | null }>(
4200
+ `SELECT child_name, runtime, model, flags_json, workdir FROM node_create_requests WHERE request_id = ?1`,
4166
4201
  request_id,
4167
4202
  );
4168
4203
  if (!specRow) {
@@ -4185,6 +4220,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4185
4220
  model: specRow.model,
4186
4221
  flags: JSON.parse(specRow.flags_json),
4187
4222
  channels: [],
4223
+ // 只在请求真带了时出现:老 daemon 看不到多余的键,新 daemon 缺席即走老布局。
4224
+ ...(specRow.workdir ? { workdir: specRow.workdir } : {}),
4188
4225
  },
4189
4226
  child_token: blob.child_token,
4190
4227
  env_blob: blob.env_blob,
@@ -4528,10 +4565,50 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4528
4565
  // Use COALESCE so a NULL inbox row is counted against the same
4529
4566
  // network as the target node (which is the only safe default
4530
4567
  // — pre-multi-network rows existed in single-network mode).
4568
+ //
4569
+ // #2022 — "unacked" alone is not "in flight". Nothing acks a row
4570
+ // whose task already reached a terminal state (report_completion,
4571
+ // send_reply, send_ack, the TTL patrol all leave inbox alone) and
4572
+ // retention sweeps only acked=1 rows, so those rows were counted
4573
+ // forever: one dead row pinned this gate to node_busy_in_flight
4574
+ // and force=true was the only way past it.
4575
+ //
4576
+ // Counting rule (#2029 review): a row counts while it can still be
4577
+ // work this node is doing — and the age ceiling must NOT be applied
4578
+ // to a task row whose tasks row we can see. agent-node acks the
4579
+ // inbox row only once the task is done, so a 90-minute (or 3-hour,
4580
+ // or ttl_seconds>3600) task keeps acked=0 for its whole run;
4581
+ // capping it by age would open this gate mid-run, which is exactly
4582
+ // what the gate exists to prevent. So:
4583
+ // 1. type='task' row with a matching tasks row — LEFT JOIN on
4584
+ // COALESCE(task_id, id), the key cancel_task / ack_inbox use
4585
+ // (first deliveries set id == task_id; retry/reassign write a
4586
+ // fresh transport id). Status alone decides: terminal
4587
+ // (replied / failed / cancelled / expired) → not counted;
4588
+ // anything else → counted, no age ceiling. Expiry belongs to
4589
+ // patrolExpiredTasks, which now acks the inbox row in the same
4590
+ // transaction, so an expired task leaves this count together
4591
+ // with its rows.
4592
+ // 2. every other row — a task row whose tasks row is gone
4593
+ // (reaped by retention, or a legacy row whose id resolves to
4594
+ // nothing) and non-task rows (reply / broadcast / message):
4595
+ // these carry no status of their own and nothing else will
4596
+ // ever retire them, so they get the age ceiling
4597
+ // IN_FLIGHT_MAX_AGE_MINUTES on inbox.created_at.
4598
+ // The count stays a per-alias inbox count: one PK join per row.
4599
+ const IN_FLIGHT_MAX_AGE_MINUTES = 60;
4531
4600
  const inFlightRow = db.get<{ n: number }>(
4532
- `SELECT COUNT(*) AS n FROM inbox WHERE session_name = ?1 AND acked = 0
4533
- AND COALESCE(network_id, ?2) = ?2`,
4534
- node.alias, node.network_id,
4601
+ `SELECT COUNT(*) AS n FROM inbox i
4602
+ LEFT JOIN tasks t
4603
+ ON i.type = 'task'
4604
+ AND t.task_id = COALESCE(i.task_id, i.id)
4605
+ WHERE i.session_name = ?1 AND i.acked = 0
4606
+ AND COALESCE(i.network_id, ?2) = ?2
4607
+ AND ((t.task_id IS NOT NULL
4608
+ AND t.status NOT IN ('replied', 'failed', 'cancelled', 'expired'))
4609
+ OR (t.task_id IS NULL
4610
+ AND i.created_at >= datetime('now', ?3)))`,
4611
+ node.alias, node.network_id, `-${IN_FLIGHT_MAX_AGE_MINUTES} minutes`,
4535
4612
  );
4536
4613
  const inFlight = inFlightRow?.n ?? 0;
4537
4614
  if (inFlight > 0 && !args.force) {