@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.
- package/package.json +1 -1
- package/src/agent-department-http.test.ts +148 -0
- package/src/chain-attempt-dedup.test.ts +447 -0
- package/src/create-node-tool-schema.test.ts +1 -0
- package/src/create-node-validate.test.ts +6 -0
- package/src/create-node-validate.ts +7 -2
- package/src/db.ts +190 -21
- package/src/departments.ts +16 -0
- package/src/env-transport-gate-http.test.ts +146 -0
- package/src/env-transport-gate.ts +74 -0
- package/src/env-transport-policy.ts +21 -0
- package/src/host-supervisor-identity-http.test.ts +20 -4
- package/src/hub-catalog-read-http.test.ts +187 -0
- package/src/node-secret-rest-http.test.ts +190 -0
- package/src/observer-avatar-http.test.ts +13 -2
- package/src/report-status-resume-id-handover.test.ts +34 -0
- package/src/report-status-secret-keys.test.ts +140 -0
- package/src/requirements-http.test.ts +1 -1
- package/src/requirements-people.ts +8 -4
- package/src/rest-projections.ts +2 -0
- package/src/schedule-agent.ts +1 -1
- package/src/scheduled-backoff-http.test.ts +193 -0
- package/src/scheduled-backoff.ts +64 -0
- package/src/scheduled-outcome-guidance-http.test.ts +139 -0
- package/src/scheduled-outcome-guidance.ts +9 -0
- package/src/scheduled-requirement-wake-http.test.ts +264 -0
- package/src/scheduled-requirement-wake.ts +125 -0
- package/src/scheduled-tasks.ts +78 -23
- package/src/scheduled-work-outcome-http.test.ts +154 -0
- package/src/scheduled-work-outcome.ts +50 -0
- package/src/scoped-secrets.ts +15 -0
- package/src/server.ts +162 -3
- 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.
|
|
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",
|