@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/package.json +1 -1
- package/src/create-node-validate.ts +34 -0
- package/src/create-node-workdir.test.ts +254 -0
- package/src/db.ts +35 -0
- package/src/inbox-count-sse.test.ts +1 -1
- package/src/requirements-http.test.ts +196 -0
- package/src/requirements.ts +256 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/server.ts +83 -5
- package/src/stop-delete-node.test.ts +212 -0
- package/src/tools.ts +87 -10
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
|
-
|
|
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
|
-
|
|
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
|
|
4533
|
-
|
|
4534
|
-
|
|
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) {
|