@sleep2agi/commhub-server 0.9.0-preview.139 → 0.9.0-preview.140

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.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/src/agent-department-http.test.ts +148 -0
  3. package/src/chain-attempt-dedup.test.ts +447 -0
  4. package/src/create-node-tool-schema.test.ts +1 -0
  5. package/src/create-node-validate.test.ts +6 -0
  6. package/src/create-node-validate.ts +7 -2
  7. package/src/db.ts +190 -21
  8. package/src/departments.ts +16 -0
  9. package/src/env-transport-gate-http.test.ts +146 -0
  10. package/src/env-transport-gate.ts +74 -0
  11. package/src/env-transport-policy.ts +21 -0
  12. package/src/host-supervisor-identity-http.test.ts +20 -4
  13. package/src/hub-catalog-read-http.test.ts +187 -0
  14. package/src/node-secret-rest-http.test.ts +190 -0
  15. package/src/observer-avatar-http.test.ts +13 -2
  16. package/src/report-status-resume-id-handover.test.ts +34 -0
  17. package/src/report-status-secret-keys.test.ts +140 -0
  18. package/src/requirements-http.test.ts +1 -1
  19. package/src/requirements-people.ts +8 -4
  20. package/src/rest-projections.ts +2 -0
  21. package/src/schedule-agent.ts +1 -1
  22. package/src/scheduled-backoff-http.test.ts +193 -0
  23. package/src/scheduled-backoff.ts +64 -0
  24. package/src/scheduled-outcome-guidance-http.test.ts +139 -0
  25. package/src/scheduled-outcome-guidance.ts +9 -0
  26. package/src/scheduled-requirement-wake-http.test.ts +264 -0
  27. package/src/scheduled-requirement-wake.ts +125 -0
  28. package/src/scheduled-tasks.ts +78 -23
  29. package/src/scheduled-work-outcome-http.test.ts +154 -0
  30. package/src/scheduled-work-outcome.ts +50 -0
  31. package/src/scoped-secrets.ts +15 -0
  32. package/src/server.ts +162 -3
  33. package/src/tools.ts +99 -16
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.139",
3
+ "version": "0.9.0-preview.140",
4
4
  "description": "CommHub Server — 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",
@@ -0,0 +1,148 @@
1
+ // Agent(节点)显式归属部门 —— `PUT /api/networks/:net/nodes/:nodeId/department`。
2
+ //
3
+ // 2026-10-19 现网:组织架构页 4 个部门各「0 个 Agent」、328 个 Agent 全在「未归属」。
4
+ // 原因不是没人管,而是后端**没有**「Agent 属于哪个部门」这个字段:那时的口径只有
5
+ // 「负责人(人)在哪个部门」,于是没有负责人 / 负责人不在部门树里的 Agent 只能算未归属。
6
+ //
7
+ // 这组用例钉住:
8
+ // 1. owner 能把 Agent 放进部门、能改、能清空;
9
+ // 2. 清空后回到「按负责人的部门推断」的旧口径(directory 里 department_id 为 null);
10
+ // 3. 别的网络的部门 / 不存在的部门 / 不存在的节点都被拒;
11
+ // 4. 普通成员不能改;本部门负责人只能在子树内调动;
12
+ // 5. 通讯录(/api/requirements/people/directory)会把显式部门带给客户端。
13
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
14
+ import { mkdtempSync, rmSync } from "fs";
15
+ import { join } from "path";
16
+ import { tmpdir } from "os";
17
+ import { register } from "./auth.js";
18
+ import { db } from "./db.js";
19
+
20
+ const DIR = mkdtempSync(join(tmpdir(), "anet-agent-dept-"));
21
+ let BASE = "";
22
+ let hub: any = null;
23
+ const PW = "AgentDeptPassw0rd!xyz";
24
+ let NET = "";
25
+ let OTHER_NET = "";
26
+ const U: Record<string, { token: string; id: string }> = {};
27
+ const D: Record<string, string> = {};
28
+ let NODE_A = "";
29
+ let NODE_OUTSIDE = "";
30
+
31
+ async function send(token: string, method: string, path: string, body?: unknown) {
32
+ const res = await fetch(`${BASE}${path}`, {
33
+ method,
34
+ headers: token ? { Authorization: `Bearer ${token}`, "Content-Type": "application/json" } : { "Content-Type": "application/json" },
35
+ body: body === undefined ? undefined : JSON.stringify(body),
36
+ });
37
+ const text = await res.text();
38
+ let json: any = null;
39
+ try { json = JSON.parse(text); } catch {}
40
+ return { status: res.status, body: json };
41
+ }
42
+ const setDept = (token: string, nodeId: string, department_id: string | null) =>
43
+ send(token, "PUT", `/api/networks/${NET}/nodes/${nodeId}/department`, { department_id });
44
+ const dept = (token: string, method: string, path = "", body?: unknown) =>
45
+ send(token, method, `/api/networks/${NET}/departments${path}`, body);
46
+
47
+ function agentRow(nodeId: string) {
48
+ return db.get<{ department_id: string | null }>("SELECT department_id FROM nodes WHERE node_id = ?1", nodeId);
49
+ }
50
+
51
+ beforeAll(async () => {
52
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
53
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
54
+ process.env.HOST = "127.0.0.1";
55
+ const a = register(`ad_owner_${Date.now()}`, PW, undefined, "Owner");
56
+ U.owner = { token: a.token!, id: a.user!.user_id };
57
+ NET = a.network_id!;
58
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [U.owner.id]);
59
+ const mod: any = await import("./server.js");
60
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
61
+ BASE = `http://127.0.0.1:${hub.port}`;
62
+
63
+ // 第二个网络:用来钉「别的网络的部门不能被借来用」。
64
+ OTHER_NET = `net_other_${Date.now()}`;
65
+ db.run("INSERT INTO networks (network_id, network_name, owner_id) VALUES (?1, ?1, ?2)", [OTHER_NET, U.owner.id]);
66
+ db.run("INSERT INTO network_members (network_id, user_id, role) VALUES (?1, ?2, 'owner')", [OTHER_NET, U.owner.id]);
67
+ db.run("INSERT INTO network_departments (network_id, department_id, name, sort, created_by) VALUES (?1, 'dept_other', '别的网', 0, ?2)", [OTHER_NET, U.owner.id]);
68
+
69
+ // 两个部门 + 一个普通成员
70
+ const made = await dept(U.owner.token, "POST", "", { name: "TMAI" });
71
+ expect([200, 201]).toContain(made.status);
72
+ D.tmai = made.body.department.id;
73
+ const made2 = await dept(U.owner.token, "POST", "", { name: "A 站" });
74
+ expect([200, 201]).toContain(made2.status);
75
+ D.a = made2.body.department.id;
76
+
77
+ const stamp = Date.now();
78
+ expect((await send(U.owner.token, "POST", "/api/admin/users", { username: `ad_member_${stamp}`, password: PW, network_id: NET, role: "member" })).status).toBe(200);
79
+ const login = await send("", "POST", "/api/auth/login", { username: `ad_member_${stamp}`, password: PW });
80
+ U.member = { token: login.body.token, id: login.body.user.user_id };
81
+
82
+ // 两个节点:一个在本网络(无负责人),一个属于别的网络。
83
+ NODE_A = `n_ad_${stamp}`;
84
+ NODE_OUTSIDE = `n_ad_out_${stamp}`;
85
+ db.run(
86
+ "INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, '测试员 3 号', '测试员 3 号', ?2, datetime('now'), datetime('now'))",
87
+ [NODE_A, NET],
88
+ );
89
+ db.run(
90
+ "INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, '别的网的节点', '别的网的节点', ?2, datetime('now'), datetime('now'))",
91
+ [NODE_OUTSIDE, OTHER_NET],
92
+ );
93
+ });
94
+
95
+ afterAll(() => {
96
+ try { hub?.stop?.(true); } catch {}
97
+ rmSync(DIR, { recursive: true, force: true });
98
+ });
99
+
100
+ describe("Agent 显式归属部门", () => {
101
+ test("owner 把 Agent 放进部门 → 落库;再改到另一个部门 → 跟随", async () => {
102
+ const first = await setDept(U.owner.token, NODE_A, D.tmai);
103
+ expect(first.status).toBe(200);
104
+ expect(first.body).toMatchObject({ ok: true, node_id: NODE_A, department_id: D.tmai });
105
+ expect(agentRow(NODE_A)!.department_id).toBe(D.tmai);
106
+
107
+ const moved = await setDept(U.owner.token, NODE_A, D.a);
108
+ expect(moved.status).toBe(200);
109
+ expect(agentRow(NODE_A)!.department_id).toBe(D.a);
110
+ });
111
+
112
+ test("清空(null / 空串)→ 回到「按负责人推断」的口径", async () => {
113
+ expect((await setDept(U.owner.token, NODE_A, D.tmai)).status).toBe(200);
114
+ const cleared = await setDept(U.owner.token, NODE_A, null);
115
+ expect(cleared.status).toBe(200);
116
+ expect(cleared.body.department_id).toBeNull();
117
+ expect(agentRow(NODE_A)!.department_id).toBeNull();
118
+
119
+ expect((await setDept(U.owner.token, NODE_A, D.tmai)).status).toBe(200);
120
+ expect((await setDept(U.owner.token, NODE_A, "")).body.department_id).toBeNull();
121
+ });
122
+
123
+ test("拒绝:别的网络的部门 / 不存在的部门 / 不存在的节点 / 别的网络的节点", async () => {
124
+ expect((await setDept(U.owner.token, NODE_A, "dept_other")).status).toBe(400);
125
+ expect((await setDept(U.owner.token, NODE_A, "dept_nope")).status).toBe(400);
126
+ expect((await setDept(U.owner.token, "n_nope", D.tmai)).status).toBe(404);
127
+ expect((await setDept(U.owner.token, NODE_OUTSIDE, D.tmai)).status).toBe(404);
128
+ });
129
+
130
+ test("普通成员改不了(403)", async () => {
131
+ const r = await setDept(U.member.token, NODE_A, D.tmai);
132
+ expect(r.status).toBe(403);
133
+ });
134
+
135
+ test("通讯录把显式部门带给客户端;清空后回到 null", async () => {
136
+ expect((await setDept(U.owner.token, NODE_A, D.a)).status).toBe(200);
137
+ const dir = await send(U.owner.token, "GET", `/api/requirements/people/directory?network_id=${NET}`);
138
+ expect(dir.status).toBe(200);
139
+ const agents = [...(dir.body.people ?? []).flatMap((p: any) => p.agents ?? []), ...(dir.body.agents_without_owner ?? [])];
140
+ const mine = agents.find((a: any) => a.node_id === NODE_A);
141
+ expect(mine?.department_id).toBe(D.a);
142
+
143
+ expect((await setDept(U.owner.token, NODE_A, null)).status).toBe(200);
144
+ const dir2 = await send(U.owner.token, "GET", `/api/requirements/people/directory?network_id=${NET}`);
145
+ const agents2 = [...(dir2.body.people ?? []).flatMap((p: any) => p.agents ?? []), ...(dir2.body.agents_without_owner ?? [])];
146
+ expect(agents2.find((a: any) => a.node_id === NODE_A)?.department_id ?? null).toBeNull();
147
+ });
148
+ });
@@ -0,0 +1,447 @@
1
+ // Node-TMAI#4 — auto-chain: attempt identity, terminal-parent safety,
2
+ // dispatcher participation.
3
+ //
4
+ // This file is the trimmed re-implementation of PR #1940 on current main. The
5
+ // original candidate carried four coupled behavioural changes; two of them
6
+ // contradict decisions this repository made AFTER that candidate was written,
7
+ // so they are deliberately NOT implemented here:
8
+ //
9
+ // * C — "an `acked` task must expire on its deadline". #519 (task-stale-open.ts)
10
+ // owns this convergence and made it **activity-based and default-off** on
11
+ // purpose: a Hub-wide TTL sweep of acked rows would close production history
12
+ // the moment it ships. `patrolExpiredTasks` therefore keeps
13
+ // `status IN ('created','delivered')`, and task-stale-open-http.test.ts pins
14
+ // that an old acked row stays open while #519 is disabled.
15
+ //
16
+ // * D(parent row) — "a child result must never complete its parent". #690
17
+ // (scheduled-run-terminal.test.ts) is built on the opposite contract: a
18
+ // scheduled/dispatched parent whose executor answers through a child must
19
+ // reach a terminal state and carry its scheduler-run mirror with it.
20
+ //
21
+ // * A — "never infer parent_task_id". #668 restored the inference on purpose
22
+ // (`send_task` would otherwise lose the chain whenever a node forgets the id)
23
+ // and narrowed it to `delivered` only.
24
+ //
25
+ // What IS implemented here, because it is a defect on current main and conflicts
26
+ // with nothing:
27
+ //
28
+ // T) A TERMINAL parent used to be rewritten: its `result` was appended and its
29
+ // `completed_at` moved forward long after it finished (observed: 22 minutes
30
+ // later). The child result is still preserved as an audit row plus a notice
31
+ // to the parent's author, but the parent row now stays byte-identical.
32
+ // O) An OPEN parent is still completed by the chain — that is the #690
33
+ // contract, pinned below so a future edit cannot quietly drop it.
34
+ // R) One ATTEMPT of a child result is recorded against a parent at most once:
35
+ // a replayed reply no longer fans out into a second audit row and a second
36
+ // notice.
37
+ // N) A fresh attempt of the same task — a legal retry, identified by its inbox
38
+ // transport row — still gets through, even when both attempts complete in
39
+ // the same second (`completed_at` has second granularity).
40
+ // E) The de-dup key binds the ORIGIN of the result, not the walk position, so
41
+ // a descendant notice cannot consume an ancestor's own completion slot.
42
+ // P) A same-network caller may not adopt an unrelated third party's task as
43
+ // its parent (`parent_not_participant`).
44
+
45
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
46
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
47
+ import { db, chainReplyToParent, uuidv4 } from "./db.js";
48
+ import { registerTools } from "./tools.js";
49
+
50
+ const NET = "net_cpt_dedup";
51
+ const USER = "u_cpt_dedup";
52
+ const PEER = "cpt-peer"; // the session dispatching work outward
53
+ const TARGET = "cpt-target"; // the session receiving the dispatch
54
+ const BOSS = "cpt-boss"; // the originator who reads the answers
55
+ const THIRD = "cpt-third"; // a third party with no involvement
56
+
57
+ type Handler = (args: any, extra?: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
58
+
59
+ function cleanup(): void {
60
+ for (const table of ["task_events", "inbox", "scheduled_task_runs", "scheduled_tasks", "tasks", "sessions", "nodes", "network_members", "networks"]) {
61
+ try { db.run(`DELETE FROM ${table} WHERE network_id = ?1`, [NET]); } catch {}
62
+ }
63
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
64
+ }
65
+
66
+ function seed(): void {
67
+ db.run("INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))", [USER, USER]);
68
+ db.run("INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))", [NET, USER]);
69
+ db.run("INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))", [USER, NET]);
70
+ for (const alias of [PEER, TARGET, BOSS, THIRD]) {
71
+ db.run(
72
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, lifecycle_state)
73
+ VALUES (?1, ?2, ?2, ?3, 'active')`,
74
+ [`n_${alias}`, alias, NET],
75
+ );
76
+ db.run(
77
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
78
+ VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
79
+ [`r_${alias}`, alias, `n_${alias}`, NET],
80
+ );
81
+ }
82
+ }
83
+
84
+ function handlers(): Record<string, Handler> {
85
+ const server = new McpServer({ name: "test-cpt-dedup", version: "0" }) as any;
86
+ const out: Record<string, Handler> = {};
87
+ const original = server.tool.bind(server);
88
+ server.tool = (name: string, ...args: any[]) => {
89
+ const h = args.at(-1);
90
+ if (typeof h === "function") out[name] = h;
91
+ return original(name, ...args);
92
+ };
93
+ registerTools(server, undefined, NET, USER, PEER, false, null);
94
+ return out;
95
+ }
96
+
97
+ async function call(handler: Handler, args: any): Promise<any> {
98
+ const r = await handler(args);
99
+ return JSON.parse(r.content[0].text);
100
+ }
101
+
102
+ function insertTask(o: {
103
+ task_id?: string;
104
+ from_name: string;
105
+ to_name: string;
106
+ status: string;
107
+ content: string;
108
+ parent_task_id?: string | null;
109
+ result?: string | null;
110
+ completed_at?: string | null;
111
+ expires_at?: string | null;
112
+ }): string {
113
+ const id = o.task_id ?? uuidv4();
114
+ db.run(
115
+ `INSERT INTO tasks
116
+ (task_id, from_name, to_name, priority, status, content, requires_response,
117
+ created_at, network_id, parent_task_id, result, completed_at, expires_at)
118
+ VALUES (?1, ?2, ?3, 'normal', ?4, ?5, 'reply', datetime('now'), ?6, ?7, ?8, ?9, ?10)`,
119
+ [id, o.from_name, o.to_name, o.status, o.content, NET, o.parent_task_id ?? null, o.result ?? null, o.completed_at ?? null, o.expires_at ?? null],
120
+ );
121
+ return id;
122
+ }
123
+
124
+ /** A dispatch is identified by its inbox transport row — the same identity the
125
+ * chain uses as its attempt key. Tests that want a "fresh attempt" insert one. */
126
+ function insertTransport(taskId: string, to = TARGET, body = "dispatch"): string {
127
+ const id = `tr_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`;
128
+ db.run(
129
+ `INSERT INTO inbox (id, session_name, type, priority, content, from_session, task_id, requires_response, network_id)
130
+ VALUES (?1, ?2, 'task', 'normal', ?3, ?4, ?5, 'reply', ?6)`,
131
+ [id, to, body, PEER, taskId, NET],
132
+ );
133
+ return id;
134
+ }
135
+
136
+ const taskById = (id: string) =>
137
+ db.get<{ status: string; result: string | null; completed_at: string | null; parent_task_id: string | null }>(
138
+ "SELECT status, result, completed_at, parent_task_id FROM tasks WHERE task_id = ?1", id);
139
+
140
+ /** Audit rows this path appends to a parent (either shape). */
141
+ const chainEvents = (parentId: string) =>
142
+ db.get<{ c: number }>(
143
+ "SELECT COUNT(*) AS c FROM task_events WHERE task_id = ?1 AND actor IN ('auto-chain','auto-chain-append')", parentId)?.c ?? 0;
144
+
145
+ const childNotices = (childText: string) =>
146
+ db.get<{ c: number }>(
147
+ "SELECT COUNT(*) AS c FROM inbox WHERE content LIKE ?1", `%${childText}%`)?.c ?? 0;
148
+
149
+ beforeEach(() => { cleanup(); seed(); });
150
+ afterAll(cleanup);
151
+
152
+ // ─────────────────────────────────────────────────────────────────────
153
+ // T — a terminal parent is never rewritten
154
+ // ─────────────────────────────────────────────────────────────────────
155
+
156
+ describe("T — a terminal parent stays byte-identical", () => {
157
+ const TERMINAL = ["replied", "failed", "cancelled", "expired"];
158
+
159
+ for (const state of TERMINAL) {
160
+ test(`parent status=${state}: status/result/completed_at unchanged; child result recorded`, () => {
161
+ const ORIGINAL = `ORIGINAL-${state}-ANSWER`;
162
+ const STAMP = "2026-01-01 00:00:00";
163
+ const parent = insertTask({
164
+ from_name: BOSS, to_name: PEER, status: state, content: `cpt-t parent in ${state}`,
165
+ result: ORIGINAL, completed_at: STAMP,
166
+ });
167
+ const child = insertTask({
168
+ from_name: PEER, to_name: TARGET, status: "replied",
169
+ content: `cpt-t child for ${state}`, parent_task_id: parent,
170
+ });
171
+ insertTransport(child);
172
+
173
+ const res = chainReplyToParent(child, `CHILD-ANSWER-FOR-${state}`, "replied", 5, NET);
174
+
175
+ const after = taskById(parent);
176
+ expect(after?.status).toBe(state);
177
+ expect(after?.result).toBe(ORIGINAL);
178
+ expect(after?.completed_at).toBe(STAMP);
179
+
180
+ // Preserved as an audit row plus a notice — never as the parent's answer.
181
+ expect(res.chained).toBe(true);
182
+ expect(chainEvents(parent)).toBe(1);
183
+ expect(childNotices(`CHILD-ANSWER-FOR-${state}`)).toBe(1);
184
+ });
185
+ }
186
+
187
+ test("a terminal parent with a NULL result does not gain one", () => {
188
+ const parent = insertTask({
189
+ from_name: BOSS, to_name: PEER, status: "cancelled", content: "cpt-t cancelled parent",
190
+ result: null, completed_at: "2026-02-02 02:02:02",
191
+ });
192
+ const child = insertTask({
193
+ from_name: PEER, to_name: TARGET, status: "replied",
194
+ content: "cpt-t child of cancelled parent", parent_task_id: parent,
195
+ });
196
+ insertTransport(child);
197
+
198
+ chainReplyToParent(child, "LATE-CHILD-ANSWER", "replied", 5, NET);
199
+
200
+ const after = taskById(parent);
201
+ expect(after?.status).toBe("cancelled");
202
+ expect(after?.result).toBeNull();
203
+ expect(after?.completed_at).toBe("2026-02-02 02:02:02");
204
+ });
205
+ });
206
+
207
+ // ─────────────────────────────────────────────────────────────────────
208
+ // O — an OPEN parent is still completed by the chain (#690 contract)
209
+ // ─────────────────────────────────────────────────────────────────────
210
+
211
+ describe("O — an open parent is still completed by the chain (#690)", () => {
212
+ for (const state of ["created", "delivered", "acked", "running"]) {
213
+ test(`parent status=${state}: reaches replied with the child result`, () => {
214
+ const parent = insertTask({
215
+ from_name: BOSS, to_name: PEER, status: state, content: `cpt-o parent in ${state}`,
216
+ });
217
+ const child = insertTask({
218
+ from_name: PEER, to_name: TARGET, status: "replied",
219
+ content: `cpt-o child for ${state}`, parent_task_id: parent,
220
+ });
221
+ insertTransport(child);
222
+
223
+ chainReplyToParent(child, `OPEN-CHILD-${state}`, "replied", 5, NET);
224
+
225
+ const after = taskById(parent);
226
+ expect(after?.status).toBe("replied");
227
+ expect(after?.result).toContain(`OPEN-CHILD-${state}`);
228
+ expect(after?.completed_at).toBeTruthy();
229
+ expect(chainEvents(parent)).toBe(1);
230
+ expect(childNotices(`OPEN-CHILD-${state}`)).toBe(1);
231
+ });
232
+ }
233
+ });
234
+
235
+ // ─────────────────────────────────────────────────────────────────────
236
+ // R — one attempt is recorded at most once
237
+ // ─────────────────────────────────────────────────────────────────────
238
+
239
+ describe("R — a replayed delivery does not fan out twice", () => {
240
+ test("same attempt replayed: one audit row, one notice, second call not chained", () => {
241
+ const parent = insertTask({
242
+ from_name: BOSS, to_name: PEER, status: "delivered", content: "cpt-r replay parent",
243
+ });
244
+ const child = insertTask({
245
+ from_name: PEER, to_name: TARGET, status: "replied",
246
+ content: "cpt-r replay child", parent_task_id: parent,
247
+ });
248
+ insertTransport(child);
249
+
250
+ const first = chainReplyToParent(child, "REPLAY-ANSWER", "replied", 5, NET);
251
+ const second = chainReplyToParent(child, "REPLAY-ANSWER", "replied", 5, NET);
252
+
253
+ expect(first.chained).toBe(true);
254
+ expect(second.chained).toBe(false);
255
+ expect(chainEvents(parent)).toBe(1);
256
+ expect(childNotices("REPLAY-ANSWER")).toBe(1);
257
+ });
258
+
259
+ test("a terminal parent dedups its audit row too", () => {
260
+ const parent = insertTask({
261
+ from_name: BOSS, to_name: PEER, status: "replied", content: "cpt-r terminal parent",
262
+ result: "ALREADY-ANSWERED", completed_at: "2026-03-03 03:03:03",
263
+ });
264
+ const child = insertTask({
265
+ from_name: PEER, to_name: TARGET, status: "replied",
266
+ content: "cpt-r terminal child", parent_task_id: parent,
267
+ });
268
+ insertTransport(child);
269
+
270
+ expect(chainReplyToParent(child, "TERMINAL-REPLAY", "replied", 5, NET).chained).toBe(true);
271
+ expect(chainReplyToParent(child, "TERMINAL-REPLAY", "replied", 5, NET).chained).toBe(false);
272
+ expect(chainEvents(parent)).toBe(1);
273
+ expect(childNotices("TERMINAL-REPLAY")).toBe(1);
274
+ });
275
+ });
276
+
277
+ // ─────────────────────────────────────────────────────────────────────
278
+ // N — a NEW attempt is not swallowed, even in the same second
279
+ // ─────────────────────────────────────────────────────────────────────
280
+
281
+ describe("N — a legal second attempt keeps its own notice", () => {
282
+ test("retry queues a new transport row → the second result still records", () => {
283
+ const parent = insertTask({
284
+ from_name: BOSS, to_name: PEER, status: "delivered", content: "cpt-n retry parent",
285
+ });
286
+ const child = insertTask({
287
+ from_name: PEER, to_name: TARGET, status: "replied",
288
+ content: "cpt-n retry child", parent_task_id: parent,
289
+ });
290
+ insertTransport(child);
291
+
292
+ expect(chainReplyToParent(child, "ATTEMPT-ONE", "replied", 5, NET).chained).toBe(true);
293
+
294
+ // retry_task: reset the task, then queue a fresh transport row (same
295
+ // transaction, new row). Both attempts carry the same second-granularity
296
+ // delivery stamp — the transport row is what tells them apart.
297
+ db.run(
298
+ "UPDATE tasks SET status = 'replied', result = NULL, completed_at = '2026-04-04 04:04:04' WHERE task_id = ?1",
299
+ [child],
300
+ );
301
+ insertTransport(child, TARGET, "retry dispatch");
302
+ db.run(
303
+ "UPDATE tasks SET status = 'replied', result = NULL, completed_at = '2026-04-04 04:04:04' WHERE task_id = ?1",
304
+ [child],
305
+ );
306
+
307
+ expect(chainReplyToParent(child, "ATTEMPT-TWO", "replied", 5, NET).chained).toBe(true);
308
+ expect(childNotices("ATTEMPT-ONE")).toBe(1);
309
+ expect(childNotices("ATTEMPT-TWO")).toBe(1);
310
+ expect(chainEvents(parent)).toBe(2);
311
+ });
312
+ });
313
+
314
+ // ─────────────────────────────────────────────────────────────────────
315
+ // E — the key binds the ORIGIN, not the walk position
316
+ // ─────────────────────────────────────────────────────────────────────
317
+
318
+ describe("E — a descendant notice does not consume the executor's own completion", () => {
319
+ test("grandparent ← parent ← leaf: leaf notice then parent completion both record", () => {
320
+ const grandparent = insertTask({
321
+ from_name: BOSS, to_name: PEER, status: "delivered", content: "cpt-e grandparent",
322
+ });
323
+ const parent = insertTask({
324
+ from_name: PEER, to_name: THIRD, status: "running",
325
+ content: "cpt-e parent", parent_task_id: grandparent,
326
+ });
327
+ const leaf = insertTask({
328
+ from_name: THIRD, to_name: TARGET, status: "replied",
329
+ content: "cpt-e leaf", parent_task_id: parent,
330
+ });
331
+ insertTransport(leaf);
332
+ insertTransport(parent, THIRD, "parent dispatch");
333
+
334
+ // (1) the leaf answers the parent; the notice also walks up to the
335
+ // grandparent.
336
+ const early = chainReplyToParent(leaf, "LEAF-EARLY", "replied", 5, NET);
337
+ expect(early.chained).toBe(true);
338
+ // Two hops carry the same reply: one notice to the parent's author and
339
+ // one to the grandparent's author.
340
+ expect(childNotices("LEAF-EARLY")).toBe(2);
341
+ const afterLeaf = chainEvents(grandparent);
342
+
343
+ // (2) the parent — a real executor — now finishes for real.
344
+ const result = chainReplyToParent(parent, "PARENT-FINAL", "replied", 5, NET);
345
+ expect(result.chained).toBe(true);
346
+ expect(childNotices("PARENT-FINAL")).toBe(1);
347
+
348
+ // The parent's own completion is a different event from the descendant
349
+ // notice that already reached the grandparent.
350
+ expect(chainEvents(grandparent)).toBe(afterLeaf + 1);
351
+ });
352
+
353
+ test("two different leaves under one parent each keep their own notice", () => {
354
+ const parent = insertTask({
355
+ from_name: BOSS, to_name: PEER, status: "delivered", content: "cpt-e shared parent",
356
+ });
357
+ const firstLeaf = insertTask({
358
+ from_name: PEER, to_name: TARGET, status: "replied",
359
+ content: "cpt-e leaf one", parent_task_id: parent,
360
+ });
361
+ const secondLeaf = insertTask({
362
+ from_name: PEER, to_name: THIRD, status: "replied",
363
+ content: "cpt-e leaf two", parent_task_id: parent,
364
+ });
365
+ insertTransport(firstLeaf);
366
+ insertTransport(secondLeaf, THIRD, "second leaf dispatch");
367
+
368
+ expect(chainReplyToParent(firstLeaf, "LEAF-ONE", "replied", 5, NET).chained).toBe(true);
369
+ expect(chainReplyToParent(secondLeaf, "LEAF-TWO", "replied", 5, NET).chained).toBe(true);
370
+
371
+ expect(childNotices("LEAF-ONE")).toBe(1);
372
+ expect(childNotices("LEAF-TWO")).toBe(1);
373
+ expect(chainEvents(parent)).toBe(2);
374
+ });
375
+ });
376
+
377
+ // ─────────────────────────────────────────────────────────────────────
378
+ // P — an explicit parent requires a participation relation
379
+ // ─────────────────────────────────────────────────────────────────────
380
+
381
+ describe("P — explicit parent requires a participation relation", () => {
382
+ test("a third party's task cannot be adopted as parent", async () => {
383
+ const stranger = insertTask({
384
+ from_name: BOSS, to_name: THIRD, status: "delivered",
385
+ content: "cpt-p a task between other parties",
386
+ });
387
+
388
+ const reply = await call(handlers().send_task, {
389
+ alias: TARGET,
390
+ task: "cpt-p attempt to adopt a stranger parent",
391
+ priority: "normal",
392
+ from_session: PEER,
393
+ network_id: NET,
394
+ parent_task_id: stranger,
395
+ });
396
+
397
+ expect(reply.ok).toBe(false);
398
+ expect(reply.error).toBe("parent_not_participant");
399
+ const spawned = db.get<{ c: number }>(
400
+ "SELECT COUNT(*) AS c FROM tasks WHERE content = ?1",
401
+ "cpt-p attempt to adopt a stranger parent",
402
+ );
403
+ expect(spawned?.c).toBe(0);
404
+ expect(taskById(stranger)?.status).toBe("delivered");
405
+ });
406
+
407
+ test("the parent's own originator may chain a follow-up onto it", async () => {
408
+ const parent = insertTask({
409
+ from_name: PEER, to_name: THIRD, status: "delivered",
410
+ content: "cpt-p parent dispatched by the peer",
411
+ });
412
+
413
+ const reply = await call(handlers().send_task, {
414
+ alias: TARGET,
415
+ task: "cpt-p originator follow-up",
416
+ priority: "normal",
417
+ from_session: PEER,
418
+ network_id: NET,
419
+ parent_task_id: parent,
420
+ });
421
+ expect(reply.ok).toBe(true);
422
+ const child = db.get<{ parent_task_id: string | null }>(
423
+ "SELECT parent_task_id FROM tasks WHERE content = ?1", "cpt-p originator follow-up");
424
+ expect(child?.parent_task_id).toBe(parent);
425
+ });
426
+
427
+ test("a cross-network parent is still refused with its own error code", async () => {
428
+ const foreign = uuidv4();
429
+ db.run(
430
+ `INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, network_id)
431
+ VALUES (?1, ?2, ?3, 'normal', 'delivered', 'cpt-p foreign parent', 'reply', datetime('now'), ?4)`,
432
+ [foreign, BOSS, THIRD, "net_other_cpt"],
433
+ );
434
+
435
+ const reply = await call(handlers().send_task, {
436
+ alias: TARGET,
437
+ task: "cpt-p cross-network parent attempt",
438
+ priority: "normal",
439
+ from_session: PEER,
440
+ network_id: NET,
441
+ parent_task_id: foreign,
442
+ });
443
+ expect(reply.ok).toBe(false);
444
+ expect(reply.error).toBe("cross_network_parent");
445
+ db.run("DELETE FROM tasks WHERE task_id = ?1", [foreign]);
446
+ });
447
+ });
@@ -22,6 +22,7 @@ describe("create_node MCP schema", () => {
22
22
  const base = { name: "v2-node", runtime: "opencode-cli" };
23
23
  expect(nodeSpec.safeParse({ ...base, opencodeGeneration: "v2" }).success).toBe(false);
24
24
  expect(nodeSpec.safeParse({ ...base, opencodeUnsafeTools: true }).success).toBe(false);
25
+ expect(nodeSpec.safeParse({ ...base, opencodeNewServer: true }).success).toBe(false);
25
26
  const flags = { opencodeGeneration: "v2", opencodeUnsafeTools: true };
26
27
  expect(nodeSpec.parse({ ...base, flags }).flags).toEqual(flags);
27
28
  expect(nodeSpec.parse(base)).toEqual(base);
@@ -241,6 +241,12 @@ describe("buildAnetArgs (§4.2.2 F2 — fully validated argv)", () => {
241
241
  expect(() => validateOpenCodeV2ProviderModel("claude-agent-sdk", undefined, { opencodeGeneration: "v2", opencodeUnsafeTools: true })).not.toThrow();
242
242
  expect(buildAnetArgs({ name: "x", runtime: "opencode-cli" })).toEqual(["node", "create", "x", "--runtime", "opencode-cli"]);
243
243
  });
244
+ test("OpenCode V2 can opt out of the host shared Server", () => {
245
+ const args = buildAnetArgs({ name: "v2", runtime: "opencode-cli", model: "stub/model",
246
+ flags: { opencodeGeneration: "v2", opencodeUnsafeTools: true, opencodeNewServer: true } });
247
+ expect(args).toContain("--opencode-new-server");
248
+ expect(() => buildAnetArgs({ name: "v1", runtime: "opencode-cli", flags: { opencodeNewServer: true } })).toThrow();
249
+ });
244
250
  test("omitted model is allowed and does not emit --model", () => {
245
251
  const args = buildAnetArgs({
246
252
  name: "x", runtime: "codex-sdk",