agent-dealer 1.0.3 → 1.0.4
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/bundle/server/dist/adapters/agent-deck.js +68 -13
- package/bundle/server/dist/adapters/agent-deck.test.js +91 -1
- package/bundle/server/dist/adapters/agent-health.js +27 -11
- package/bundle/server/dist/adapters/agent-health.test.js +19 -0
- package/bundle/server/dist/coordinator/admission.js +41 -30
- package/bundle/server/dist/coordinator/admission.test.js +57 -2
- package/bundle/server/dist/coordinator/usage-cap-defer.js +37 -0
- package/bundle/server/dist/coordinator/worker-loop.js +20 -2
- package/bundle/server/dist/coordinator/worker-loop.test.js +55 -2
- package/bundle/server/package.json +2 -2
- package/bundle/shared/package.json +1 -1
- package/package.json +1 -1
|
@@ -154,34 +154,89 @@ export async function updatePlaybookBody(playbookId, body) {
|
|
|
154
154
|
export function readClaudeMcpConfigPath() {
|
|
155
155
|
return process.env.CLAUDE_MCP_CONFIG ?? path.join(process.env.HOME ?? "", ".claude.json");
|
|
156
156
|
}
|
|
157
|
-
|
|
157
|
+
function expectedAgentDeckMcpEndpoint() {
|
|
158
|
+
const expected = new URL(getAgentDeckMcpUrl().replace(/\/mcp\/?$/, "") + "/mcp");
|
|
159
|
+
const port = expected.port || (expected.protocol === "https:" ? "443" : "80");
|
|
160
|
+
return { hostname: expected.hostname, port };
|
|
161
|
+
}
|
|
162
|
+
function isAgentDeckMcpServerName(name) {
|
|
163
|
+
const lower = name.toLowerCase();
|
|
164
|
+
return lower.includes("agent-deck") || name === "agent-deck";
|
|
165
|
+
}
|
|
166
|
+
/** Basename of `agent-deck` (PATH lookup or absolute install path). */
|
|
167
|
+
function commandInvokesAgentDeck(command) {
|
|
168
|
+
if (!command?.trim())
|
|
169
|
+
return false;
|
|
170
|
+
const base = path.basename(command.trim()).replace(/\.(cmd|exe|bat)$/i, "");
|
|
171
|
+
return base === "agent-deck";
|
|
172
|
+
}
|
|
173
|
+
function argsIncludeMcpLaunch(args) {
|
|
174
|
+
return Array.isArray(args) && args.some((a) => a === "mcp-launch");
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Classify Claude MCP config for Agent Deck.
|
|
178
|
+
* Accepts legacy HTTP `url` entries and current `agent-deck mcp-launch` stdio + env ports
|
|
179
|
+
* from `agent-deck setup --client claude`.
|
|
180
|
+
*/
|
|
181
|
+
export function checkAgentDeckMcpRegistration() {
|
|
158
182
|
try {
|
|
159
183
|
const configPath = readClaudeMcpConfigPath();
|
|
160
184
|
if (!fs.existsSync(configPath))
|
|
161
|
-
return
|
|
185
|
+
return { status: "missing" };
|
|
162
186
|
const raw = fs.readFileSync(configPath, "utf8");
|
|
163
187
|
const config = JSON.parse(raw);
|
|
164
|
-
const expected =
|
|
188
|
+
const expected = expectedAgentDeckMcpEndpoint();
|
|
189
|
+
let mismatch = null;
|
|
165
190
|
for (const [name, server] of Object.entries(config.mcpServers ?? {})) {
|
|
166
|
-
if (!name
|
|
191
|
+
if (!isAgentDeckMcpServerName(name))
|
|
167
192
|
continue;
|
|
168
|
-
if (
|
|
193
|
+
if (server.url) {
|
|
194
|
+
try {
|
|
195
|
+
const u = new URL(server.url);
|
|
196
|
+
const foundPort = u.port || (u.protocol === "https:" ? "443" : "80");
|
|
197
|
+
if (u.hostname === expected.hostname && foundPort === expected.port) {
|
|
198
|
+
return { status: "registered" };
|
|
199
|
+
}
|
|
200
|
+
mismatch = {
|
|
201
|
+
status: "endpoint_mismatch",
|
|
202
|
+
expectedHost: expected.hostname,
|
|
203
|
+
expectedPort: expected.port,
|
|
204
|
+
foundHost: u.hostname,
|
|
205
|
+
foundPort,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// skip invalid url
|
|
210
|
+
}
|
|
169
211
|
continue;
|
|
170
|
-
try {
|
|
171
|
-
const u = new URL(server.url);
|
|
172
|
-
if (u.hostname === expected.hostname && u.port === expected.port)
|
|
173
|
-
return true;
|
|
174
212
|
}
|
|
175
|
-
|
|
176
|
-
|
|
213
|
+
// Current Claude setup: stdio `agent-deck mcp-launch` with AGENT_DECK_* env.
|
|
214
|
+
if (commandInvokesAgentDeck(server.command) && argsIncludeMcpLaunch(server.args)) {
|
|
215
|
+
const foundHost = server.env?.AGENT_DECK_HOST?.trim() || "127.0.0.1";
|
|
216
|
+
const foundPort = server.env?.AGENT_DECK_MCP_PORT?.trim() ?? "";
|
|
217
|
+
if (foundHost === expected.hostname && foundPort === expected.port) {
|
|
218
|
+
return { status: "registered" };
|
|
219
|
+
}
|
|
220
|
+
mismatch = {
|
|
221
|
+
status: "endpoint_mismatch",
|
|
222
|
+
expectedHost: expected.hostname,
|
|
223
|
+
expectedPort: expected.port,
|
|
224
|
+
foundHost,
|
|
225
|
+
foundPort: foundPort || "(missing)",
|
|
226
|
+
};
|
|
177
227
|
}
|
|
178
228
|
}
|
|
179
|
-
|
|
229
|
+
if (mismatch)
|
|
230
|
+
return mismatch;
|
|
231
|
+
return { status: "missing" };
|
|
180
232
|
}
|
|
181
233
|
catch {
|
|
182
|
-
return
|
|
234
|
+
return { status: "missing" };
|
|
183
235
|
}
|
|
184
236
|
}
|
|
237
|
+
export function isAgentDeckMcpRegistered() {
|
|
238
|
+
return checkAgentDeckMcpRegistration().status === "registered";
|
|
239
|
+
}
|
|
185
240
|
/** Map PRD toolCall to deck MCP call_service_tool args (serviceName → serviceId). */
|
|
186
241
|
export function toCallServiceToolPayload(toolCall) {
|
|
187
242
|
return {
|
|
@@ -8,8 +8,30 @@ import os from "node:os";
|
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-agentdeck-"));
|
|
10
10
|
const { migrate } = await import("../db/index.js");
|
|
11
|
-
const { fetchDecks, fetchAuthorizedDecks } = await import("./agent-deck.js");
|
|
11
|
+
const { fetchDecks, fetchAuthorizedDecks, isAgentDeckMcpRegistered, checkAgentDeckMcpRegistration, } = await import("./agent-deck.js");
|
|
12
12
|
migrate();
|
|
13
|
+
function writeClaudeMcpFixture(mcpServers) {
|
|
14
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-mcp-"));
|
|
15
|
+
const configPath = path.join(dir, ".claude.json");
|
|
16
|
+
fs.writeFileSync(configPath, JSON.stringify({ mcpServers }, null, 2));
|
|
17
|
+
return configPath;
|
|
18
|
+
}
|
|
19
|
+
function withClaudeMcpConfig(configPath, fn) {
|
|
20
|
+
const prev = process.env.CLAUDE_MCP_CONFIG;
|
|
21
|
+
if (configPath === null)
|
|
22
|
+
delete process.env.CLAUDE_MCP_CONFIG;
|
|
23
|
+
else
|
|
24
|
+
process.env.CLAUDE_MCP_CONFIG = configPath;
|
|
25
|
+
try {
|
|
26
|
+
return fn();
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
if (prev === undefined)
|
|
30
|
+
delete process.env.CLAUDE_MCP_CONFIG;
|
|
31
|
+
else
|
|
32
|
+
process.env.CLAUDE_MCP_CONFIG = prev;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
13
35
|
test("fetchDecks returns decks on success from /api/launch/decks", async () => {
|
|
14
36
|
let capturedUrl = "";
|
|
15
37
|
const fetchMock = mock.method(globalThis, "fetch", async (url, init) => {
|
|
@@ -61,3 +83,71 @@ test("fetchDecks reports DECK_UNAVAILABLE on a network error, never an empty lis
|
|
|
61
83
|
test("fetchAuthorizedDecks is an alias of fetchDecks", () => {
|
|
62
84
|
assert.equal(fetchAuthorizedDecks, fetchDecks);
|
|
63
85
|
});
|
|
86
|
+
// Default Agent Deck MCP endpoint under a fresh AGENT_DEALER_HOME is 127.0.0.1:1110
|
|
87
|
+
// (API port 1111 − 1). Fixtures below match that unless noted.
|
|
88
|
+
test("isAgentDeckMcpRegistered: stdio mcp-launch with matching env is registered (no url)", () => {
|
|
89
|
+
const configPath = writeClaudeMcpFixture({
|
|
90
|
+
"agent-deck": {
|
|
91
|
+
type: "stdio",
|
|
92
|
+
command: "agent-deck",
|
|
93
|
+
args: ["mcp-launch"],
|
|
94
|
+
env: { AGENT_DECK_MCP_PORT: "1110", AGENT_DECK_HOST: "127.0.0.1" },
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
withClaudeMcpConfig(configPath, () => {
|
|
98
|
+
assert.equal(isAgentDeckMcpRegistered(), true);
|
|
99
|
+
assert.equal(checkAgentDeckMcpRegistration().status, "registered");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
test("isAgentDeckMcpRegistered: legacy HTTP url with matching host/port is registered", () => {
|
|
103
|
+
const configPath = writeClaudeMcpFixture({
|
|
104
|
+
"agent-deck": { url: "http://127.0.0.1:1110/mcp" },
|
|
105
|
+
});
|
|
106
|
+
withClaudeMcpConfig(configPath, () => {
|
|
107
|
+
assert.equal(isAgentDeckMcpRegistered(), true);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
test("isAgentDeckMcpRegistered: missing entry returns false / missing", () => {
|
|
111
|
+
const configPath = writeClaudeMcpFixture({
|
|
112
|
+
other: { url: "http://127.0.0.1:9999/mcp" },
|
|
113
|
+
});
|
|
114
|
+
withClaudeMcpConfig(configPath, () => {
|
|
115
|
+
assert.equal(isAgentDeckMcpRegistered(), false);
|
|
116
|
+
assert.equal(checkAgentDeckMcpRegistration().status, "missing");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
test("checkAgentDeckMcpRegistration: wrong stdio port is endpoint_mismatch, not registered", () => {
|
|
120
|
+
const configPath = writeClaudeMcpFixture({
|
|
121
|
+
"agent-deck": {
|
|
122
|
+
type: "stdio",
|
|
123
|
+
command: "agent-deck",
|
|
124
|
+
args: ["mcp-launch"],
|
|
125
|
+
env: { AGENT_DECK_MCP_PORT: "9999", AGENT_DECK_HOST: "127.0.0.1" },
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
withClaudeMcpConfig(configPath, () => {
|
|
129
|
+
assert.equal(isAgentDeckMcpRegistered(), false);
|
|
130
|
+
const result = checkAgentDeckMcpRegistration();
|
|
131
|
+
assert.equal(result.status, "endpoint_mismatch");
|
|
132
|
+
if (result.status === "endpoint_mismatch") {
|
|
133
|
+
assert.equal(result.expectedPort, "1110");
|
|
134
|
+
assert.equal(result.foundPort, "9999");
|
|
135
|
+
assert.equal(result.expectedHost, "127.0.0.1");
|
|
136
|
+
assert.equal(result.foundHost, "127.0.0.1");
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
test("isAgentDeckMcpRegistered: stdio without mcp-launch args is not registered", () => {
|
|
141
|
+
const configPath = writeClaudeMcpFixture({
|
|
142
|
+
"agent-deck": {
|
|
143
|
+
type: "stdio",
|
|
144
|
+
command: "agent-deck",
|
|
145
|
+
args: ["mcp"],
|
|
146
|
+
env: { AGENT_DECK_MCP_PORT: "1110", AGENT_DECK_HOST: "127.0.0.1" },
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
withClaudeMcpConfig(configPath, () => {
|
|
150
|
+
assert.equal(isAgentDeckMcpRegistered(), false);
|
|
151
|
+
assert.equal(checkAgentDeckMcpRegistration().status, "missing");
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
2
|
import { CODEX_AUTH_REMEDIATION, cursorAuthIssueFromOutput, runtimeAuthIssueFromOutput, } from "@agent-dealer/shared";
|
|
3
3
|
import { claudeBinExists, cursorBinExists, resolveClaudeBin, cursorInvokeArgs, resolveCursorBin, resolveCodexBin, codexBinExists, } from "../cli-env.js";
|
|
4
|
-
import { checkAgentDeckHealth, fetchDecks,
|
|
4
|
+
import { checkAgentDeckHealth, checkAgentDeckMcpRegistration, fetchDecks, } from "./agent-deck.js";
|
|
5
5
|
import { runtimeAvailability } from "../repository/runtime-availability.js";
|
|
6
6
|
const RUNTIME_LABEL = {
|
|
7
7
|
claude_code: "Claude",
|
|
@@ -345,7 +345,15 @@ async function runtimeIssues(runtime) {
|
|
|
345
345
|
runtimeIssueCache.set(runtime, { at: Date.now(), issues: nonCap, softProbeFailure });
|
|
346
346
|
return [...capIssues, ...nonCap];
|
|
347
347
|
}
|
|
348
|
-
function
|
|
348
|
+
function resolveMcpRegistration(mcpRegistered) {
|
|
349
|
+
if (mcpRegistered === undefined)
|
|
350
|
+
return checkAgentDeckMcpRegistration();
|
|
351
|
+
if (typeof mcpRegistered === "boolean") {
|
|
352
|
+
return mcpRegistered ? { status: "registered" } : { status: "missing" };
|
|
353
|
+
}
|
|
354
|
+
return mcpRegistered;
|
|
355
|
+
}
|
|
356
|
+
function agentSpecificIssues(agent, agentDeckOnline, mcpRegistration, deckAccessResult) {
|
|
349
357
|
const issues = [];
|
|
350
358
|
if (!agent.deckId) {
|
|
351
359
|
issues.push({
|
|
@@ -372,11 +380,19 @@ function agentSpecificIssues(agent, agentDeckOnline, mcpRegistered, deckAccessRe
|
|
|
372
380
|
});
|
|
373
381
|
}
|
|
374
382
|
}
|
|
375
|
-
if (agent.runtime === "claude_code" && agentDeckOnline
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
383
|
+
if (agent.runtime === "claude_code" && agentDeckOnline) {
|
|
384
|
+
if (mcpRegistration.status === "endpoint_mismatch") {
|
|
385
|
+
issues.push({
|
|
386
|
+
code: "mcp_not_registered",
|
|
387
|
+
message: `Claude MCP points at ${mcpRegistration.foundHost}:${mcpRegistration.foundPort}, expected ${mcpRegistration.expectedHost}:${mcpRegistration.expectedPort} — update AGENT_DECK_HOST/AGENT_DECK_MCP_PORT (or the HTTP url) in Claude MCP config`,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
else if (mcpRegistration.status === "missing") {
|
|
391
|
+
issues.push({
|
|
392
|
+
code: "mcp_not_registered",
|
|
393
|
+
message: "Run agent-deck setup --client claude --start (Claude MCP not registered)",
|
|
394
|
+
});
|
|
395
|
+
}
|
|
380
396
|
}
|
|
381
397
|
return issues;
|
|
382
398
|
}
|
|
@@ -384,12 +400,12 @@ export async function healthForAgent(agent, agentDeckOnline, runtimeIssuesByRunt
|
|
|
384
400
|
const runtime = runtimeIssuesByRuntime !== undefined
|
|
385
401
|
? (runtimeIssuesByRuntime.get(agent.runtime) ?? [])
|
|
386
402
|
: await runtimeIssues(agent.runtime);
|
|
387
|
-
const
|
|
403
|
+
const mcpRegistration = resolveMcpRegistration(mcpRegistered);
|
|
388
404
|
const github = githubIssuesList ?? (await githubIssues());
|
|
389
405
|
const issues = [
|
|
390
406
|
...runtime,
|
|
391
407
|
...github,
|
|
392
|
-
...agentSpecificIssues(agent, agentDeckOnline,
|
|
408
|
+
...agentSpecificIssues(agent, agentDeckOnline, mcpRegistration, deckAccessResult),
|
|
393
409
|
];
|
|
394
410
|
return {
|
|
395
411
|
...agent,
|
|
@@ -399,7 +415,7 @@ export async function healthForAgent(agent, agentDeckOnline, runtimeIssuesByRunt
|
|
|
399
415
|
}
|
|
400
416
|
export async function listAgentsWithHealth(agents) {
|
|
401
417
|
const agentDeckOnline = await checkAgentDeckHealth();
|
|
402
|
-
const
|
|
418
|
+
const mcpRegistration = checkAgentDeckMcpRegistration();
|
|
403
419
|
const needsDeckAccess = agents.some((a) => a.deckId);
|
|
404
420
|
const deckAccessResult = agentDeckOnline && needsDeckAccess ? await fetchDecks() : null;
|
|
405
421
|
const runtimes = [...new Set(agents.map((a) => a.runtime))];
|
|
@@ -410,5 +426,5 @@ export async function listAgentsWithHealth(agents) {
|
|
|
410
426
|
})),
|
|
411
427
|
githubIssues(),
|
|
412
428
|
]);
|
|
413
|
-
return Promise.all(agents.map((a) => healthForAgent(a, agentDeckOnline, runtimeIssuesByRuntime,
|
|
429
|
+
return Promise.all(agents.map((a) => healthForAgent(a, agentDeckOnline, runtimeIssuesByRuntime, mcpRegistration, deckAccessResult, githubIssuesList)));
|
|
414
430
|
}
|
|
@@ -343,4 +343,23 @@ The keychain item is stuck. Delete it and sign in again:
|
|
|
343
343
|
clearAgentHealthCaches();
|
|
344
344
|
}
|
|
345
345
|
});
|
|
346
|
+
test("claude MCP endpoint mismatch surfaces a distinct message, not the generic setup hint", async () => {
|
|
347
|
+
const agent = createAgent({
|
|
348
|
+
name: "claude-mcp-mismatch",
|
|
349
|
+
runtime: "claude_code",
|
|
350
|
+
deckId: randomUUID(),
|
|
351
|
+
});
|
|
352
|
+
const result = await healthForAgent(agent, true, new Map(), {
|
|
353
|
+
status: "endpoint_mismatch",
|
|
354
|
+
expectedHost: "127.0.0.1",
|
|
355
|
+
expectedPort: "1110",
|
|
356
|
+
foundHost: "127.0.0.1",
|
|
357
|
+
foundPort: "9999",
|
|
358
|
+
}, null, NO_GITHUB);
|
|
359
|
+
const issue = result.issues.find((i) => i.code === "mcp_not_registered");
|
|
360
|
+
assert.ok(issue, "expected mcp_not_registered");
|
|
361
|
+
assert.match(issue.message, /points at 127\.0\.0\.1:9999/);
|
|
362
|
+
assert.match(issue.message, /expected 127\.0\.0\.1:1110/);
|
|
363
|
+
assert.equal(issue.message.includes("Run agent-deck setup"), false);
|
|
364
|
+
});
|
|
346
365
|
}); // describe agent-health (serial)
|
|
@@ -55,36 +55,47 @@ function listOccupyingIssues() {
|
|
|
55
55
|
return listIssues([...occupyingStatuses]).map((i) => ({ id: i.id, status: i.status }));
|
|
56
56
|
}
|
|
57
57
|
let healthChecker = null;
|
|
58
|
-
/** Tests inject a pure checker so admission
|
|
58
|
+
/** Tests inject a pure checker so admission / pre-spawn health do not hit real CLIs. */
|
|
59
59
|
export function setAdmissionHealthCheckerForTests(checker) {
|
|
60
60
|
healthChecker = checker;
|
|
61
61
|
}
|
|
62
|
-
async function defaultAgentHealth(agent, deckOnline) {
|
|
62
|
+
async function defaultAgentHealth(agent, role, deckOnline) {
|
|
63
|
+
// Unit/CI tests must not call real CLIs / Agent Deck — runners usually have no deck MCP.
|
|
64
|
+
// Production leaves this unset. NOT-133 clears it so the real auth classifier still runs.
|
|
65
|
+
if (process.env.AGENT_DEALER_SKIP_AGENT_HEALTH === "1") {
|
|
66
|
+
return { ok: true };
|
|
67
|
+
}
|
|
63
68
|
const health = await healthForAgent(agent, deckOnline);
|
|
64
69
|
// usage_capped is owned by runtimeAvailable — keep agentsHealthy for CLI/workspace/deck.
|
|
65
70
|
const nonCap = health.issues.filter((i) => i.code !== "usage_capped");
|
|
66
71
|
if (nonCap.length > 0) {
|
|
67
72
|
return {
|
|
68
73
|
ok: false,
|
|
69
|
-
|
|
74
|
+
// NOT-156: name the role so a parked review is not read as a blocked developer admit.
|
|
75
|
+
reason: `${role} unhealthy: ${agent.name} — ${nonCap.map((i) => i.message).join("; ")}`,
|
|
70
76
|
};
|
|
71
77
|
}
|
|
72
78
|
return { ok: true };
|
|
73
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* Fail-closed health for one role (NOT-156). Admission checks the role about to start
|
|
82
|
+
* (developer for `startWorkflowCore`); the worker loop re-checks the leased item's role
|
|
83
|
+
* before spawn so a later reviewer round parks without burning infra when only the
|
|
84
|
+
* reviewer is unhealthy.
|
|
85
|
+
*/
|
|
86
|
+
export async function checkRoleAgentHealthy(issue, role, ctx) {
|
|
87
|
+
const agentId = role === "developer" ? issue.developerAgentId : issue.reviewerAgentId;
|
|
88
|
+
if (!agentId)
|
|
89
|
+
return { ok: false, reason: `missing ${role} agent` };
|
|
90
|
+
const agent = getAgent(agentId);
|
|
91
|
+
if (!agent)
|
|
92
|
+
return { ok: false, reason: `${role} agent not found` };
|
|
93
|
+
const check = healthChecker ?? ((a, r) => defaultAgentHealth(a, r, ctx.deckOnline));
|
|
94
|
+
return check(agent, role);
|
|
95
|
+
}
|
|
74
96
|
async function checkAgentsHealthy(issue, ctx) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const agentId = role === "developer" ? issue.developerAgentId : issue.reviewerAgentId;
|
|
78
|
-
if (!agentId)
|
|
79
|
-
return { ok: false, reason: `missing ${role} agent` };
|
|
80
|
-
const agent = getAgent(agentId);
|
|
81
|
-
if (!agent)
|
|
82
|
-
return { ok: false, reason: `${role} agent not found` };
|
|
83
|
-
const result = await check(agent);
|
|
84
|
-
if (!result.ok)
|
|
85
|
-
return result;
|
|
86
|
-
}
|
|
87
|
-
return { ok: true };
|
|
97
|
+
// Admit always starts a developer round — do not require reviewer health yet (NOT-156).
|
|
98
|
+
return checkRoleAgentHealthy(issue, "developer", ctx);
|
|
88
99
|
}
|
|
89
100
|
/**
|
|
90
101
|
* Status must be startable (`ready`, or `needs_human` with no active workflow) and
|
|
@@ -107,20 +118,20 @@ function issueReadinessRule(issue) {
|
|
|
107
118
|
return { ok: true };
|
|
108
119
|
}
|
|
109
120
|
function runtimeAvailableRule(issue) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
123
|
-
}
|
|
121
|
+
// NOT-156: only the developer runtime gates admit; reviewer caps are enforced at lease
|
|
122
|
+
// time in the worker loop (same place as role health for the active work item).
|
|
123
|
+
const agentId = issue.developerAgentId;
|
|
124
|
+
if (!agentId)
|
|
125
|
+
return { ok: true };
|
|
126
|
+
const agent = getAgent(agentId);
|
|
127
|
+
if (!agent)
|
|
128
|
+
return { ok: true };
|
|
129
|
+
const avail = runtimeAvailability(agent.runtime);
|
|
130
|
+
if (!avail.available) {
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
reason: `runtime capped: ${agent.runtime} until ${avail.until} (${avail.reason})`,
|
|
134
|
+
};
|
|
124
135
|
}
|
|
125
136
|
return { ok: true };
|
|
126
137
|
}
|
|
@@ -310,7 +310,10 @@ test("NOT-133: a logged-out Cursor runtime is not admitted; it waits with an aut
|
|
|
310
310
|
fs.writeFileSync(stub, `#!/bin/sh\ncat ${JSON.stringify(fixture)}\nexit 0\n`);
|
|
311
311
|
fs.chmodSync(stub, 0o755);
|
|
312
312
|
const prev = process.env.CURSOR_CLI;
|
|
313
|
+
const prevSkipHealth = process.env.AGENT_DEALER_SKIP_AGENT_HEALTH;
|
|
313
314
|
process.env.CURSOR_CLI = stub;
|
|
315
|
+
// Real classifier path — must not short-circuit via the unit-test skip.
|
|
316
|
+
delete process.env.AGENT_DEALER_SKIP_AGENT_HEALTH;
|
|
314
317
|
setAdmissionHealthCheckerForTests(null);
|
|
315
318
|
clearAgentHealthCaches();
|
|
316
319
|
try {
|
|
@@ -323,7 +326,7 @@ test("NOT-133: a logged-out Cursor runtime is not admitted; it waits with an aut
|
|
|
323
326
|
assert.equal(getActiveWorkflowInstance(issue.id), null);
|
|
324
327
|
const entry = getQueuedEntryForIssue(issue.id);
|
|
325
328
|
assert.equal(entry?.state, "queued");
|
|
326
|
-
assert.match(entry.waitReason, /
|
|
329
|
+
assert.match(entry.waitReason, /developer unhealthy/);
|
|
327
330
|
assert.match(entry.waitReason, /not authenticated/i);
|
|
328
331
|
assert.match(entry.waitReason, /cursor-agent login/);
|
|
329
332
|
}
|
|
@@ -332,10 +335,56 @@ test("NOT-133: a logged-out Cursor runtime is not admitted; it waits with an aut
|
|
|
332
335
|
delete process.env.CURSOR_CLI;
|
|
333
336
|
else
|
|
334
337
|
process.env.CURSOR_CLI = prev;
|
|
338
|
+
if (prevSkipHealth === undefined)
|
|
339
|
+
delete process.env.AGENT_DEALER_SKIP_AGENT_HEALTH;
|
|
340
|
+
else
|
|
341
|
+
process.env.AGENT_DEALER_SKIP_AGENT_HEALTH = prevSkipHealth;
|
|
335
342
|
clearAgentHealthCaches();
|
|
336
343
|
setAdmissionHealthCheckerForTests(async () => ({ ok: true }));
|
|
337
344
|
}
|
|
338
345
|
});
|
|
346
|
+
// NOT-156: reviewer health must not block developer admit; developer health still fails closed.
|
|
347
|
+
test("NOT-156: developer healthy + reviewer unhealthy still admits and leases developer work", async () => {
|
|
348
|
+
const { listWorkItemsForIssue } = await import("../repository/work-items.js");
|
|
349
|
+
const issue = readyIssue("rev-unhealthy");
|
|
350
|
+
setAdmissionHealthCheckerForTests(async (_agent, role) => {
|
|
351
|
+
if (role === "reviewer") {
|
|
352
|
+
return {
|
|
353
|
+
ok: false,
|
|
354
|
+
reason: `reviewer unhealthy: ${_agent.name} — Run agent-deck setup --client claude --start (Claude MCP not registered)`,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
return { ok: true };
|
|
358
|
+
});
|
|
359
|
+
enqueueIssue(issue.id);
|
|
360
|
+
const result = await admitNext();
|
|
361
|
+
assert.equal(result?.issueId, issue.id);
|
|
362
|
+
assert.equal(getIssue(issue.id).status, "developing");
|
|
363
|
+
assert.ok(getActiveWorkflowInstance(issue.id));
|
|
364
|
+
const developerItems = listWorkItemsForIssue(issue.id).filter((w) => w.kind === "developer");
|
|
365
|
+
assert.equal(developerItems.length, 1);
|
|
366
|
+
assert.equal(developerItems[0].status, "pending");
|
|
367
|
+
});
|
|
368
|
+
test("NOT-156: developer unhealthy + reviewer healthy parks with a developer-named wait_reason", async () => {
|
|
369
|
+
const issue = readyIssue("dev-unhealthy");
|
|
370
|
+
setAdmissionHealthCheckerForTests(async (_agent, role) => {
|
|
371
|
+
if (role === "developer") {
|
|
372
|
+
return {
|
|
373
|
+
ok: false,
|
|
374
|
+
reason: `developer unhealthy: ${_agent.name} — not authenticated — run cursor-agent login`,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
return { ok: true };
|
|
378
|
+
});
|
|
379
|
+
enqueueIssue(issue.id);
|
|
380
|
+
assert.equal(await admitNext(), null);
|
|
381
|
+
assert.equal(getIssue(issue.id).status, "ready");
|
|
382
|
+
assert.equal(getActiveWorkflowInstance(issue.id), null);
|
|
383
|
+
const entry = getQueuedEntryForIssue(issue.id);
|
|
384
|
+
assert.equal(entry.state, "queued");
|
|
385
|
+
assert.match(entry.waitReason ?? "", /developer unhealthy/);
|
|
386
|
+
assert.doesNotMatch(entry.waitReason ?? "", /reviewer unhealthy/);
|
|
387
|
+
});
|
|
339
388
|
// NOT-157: soft probe failure wait_reason must name the probe, not "not authenticated".
|
|
340
389
|
test("NOT-157: soft Cursor probe timeout waits with probe-timeout reason, not logged-out copy", async () => {
|
|
341
390
|
const { clearAgentHealthCaches, setCursorProbeTimingForTests, } = await import("../adapters/agent-health.js");
|
|
@@ -346,7 +395,9 @@ test("NOT-157: soft Cursor probe timeout waits with probe-timeout reason, not lo
|
|
|
346
395
|
fs.writeFileSync(stub, "#!/bin/sh\nsleep 30\n");
|
|
347
396
|
fs.chmodSync(stub, 0o755);
|
|
348
397
|
const prev = process.env.CURSOR_CLI;
|
|
398
|
+
const prevSkipHealth = process.env.AGENT_DEALER_SKIP_AGENT_HEALTH;
|
|
349
399
|
process.env.CURSOR_CLI = stub;
|
|
400
|
+
delete process.env.AGENT_DEALER_SKIP_AGENT_HEALTH;
|
|
350
401
|
setCursorProbeTimingForTests({ timeoutMs: 150, retryBackoffsMs: [20] });
|
|
351
402
|
setAdmissionHealthCheckerForTests(null);
|
|
352
403
|
clearAgentHealthCaches();
|
|
@@ -358,7 +409,7 @@ test("NOT-157: soft Cursor probe timeout waits with probe-timeout reason, not lo
|
|
|
358
409
|
assert.equal(await admitNext(), null);
|
|
359
410
|
const entry = getQueuedEntryForIssue(issue.id);
|
|
360
411
|
assert.equal(entry?.state, "queued");
|
|
361
|
-
assert.match(entry.waitReason, /
|
|
412
|
+
assert.match(entry.waitReason, /developer unhealthy/);
|
|
362
413
|
assert.match(entry.waitReason, /probe timed out/i);
|
|
363
414
|
assert.doesNotMatch(entry.waitReason, /not authenticated/i);
|
|
364
415
|
}
|
|
@@ -367,6 +418,10 @@ test("NOT-157: soft Cursor probe timeout waits with probe-timeout reason, not lo
|
|
|
367
418
|
delete process.env.CURSOR_CLI;
|
|
368
419
|
else
|
|
369
420
|
process.env.CURSOR_CLI = prev;
|
|
421
|
+
if (prevSkipHealth === undefined)
|
|
422
|
+
delete process.env.AGENT_DEALER_SKIP_AGENT_HEALTH;
|
|
423
|
+
else
|
|
424
|
+
process.env.AGENT_DEALER_SKIP_AGENT_HEALTH = prevSkipHealth;
|
|
370
425
|
setCursorProbeTimingForTests(null);
|
|
371
426
|
clearAgentHealthCaches();
|
|
372
427
|
setAdmissionHealthCheckerForTests(async () => ({ ok: true }));
|
|
@@ -179,6 +179,43 @@ export function deferLeasedWorkItemForDeckOutage(item, leaseToken, outage, issue
|
|
|
179
179
|
});
|
|
180
180
|
})();
|
|
181
181
|
}
|
|
182
|
+
export function agentUnhealthyDeferralStartedAt(payload) {
|
|
183
|
+
const v = payload.agentUnhealthySince;
|
|
184
|
+
return typeof v === "string" && v ? v : null;
|
|
185
|
+
}
|
|
186
|
+
export function agentUnhealthyDeferralCount(payload) {
|
|
187
|
+
const v = payload.agentUnhealthyDeferrals;
|
|
188
|
+
return typeof v === "number" && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* NOT-156: the active role failed health before spawn. Same shape as a deck outage — no
|
|
192
|
+
* infra burn, exponential backoff, never escalate (a false `mcp_not_registered` must not
|
|
193
|
+
* open a human gate; the role recovers when health does).
|
|
194
|
+
*/
|
|
195
|
+
export function deferLeasedWorkItemForAgentUnhealthy(item, leaseToken, unhealthy, issue, instance, nowMs = Date.now()) {
|
|
196
|
+
return getDb().transaction(() => {
|
|
197
|
+
const live = liveLeasedItem(item.id, leaseToken);
|
|
198
|
+
if (!live)
|
|
199
|
+
return { deferred: false, escalated: false, reason: "lease_lost" };
|
|
200
|
+
const payload = parsePayload(live.payloadJson);
|
|
201
|
+
const firstDeferredAt = agentUnhealthyDeferralStartedAt(payload) ?? new Date(nowMs).toISOString();
|
|
202
|
+
const priorDeferrals = agentUnhealthyDeferralCount(payload);
|
|
203
|
+
const until = new Date(nowMs + deckOutageBackoffMs(priorDeferrals)).toISOString();
|
|
204
|
+
return applyDeferral(live, leaseToken, issue, instance, {
|
|
205
|
+
until,
|
|
206
|
+
reason: unhealthy.reason,
|
|
207
|
+
outcome: "agent_unhealthy",
|
|
208
|
+
error: { kind: "agent_unhealthy", until, reason: unhealthy.reason, evidence: unhealthy.evidence },
|
|
209
|
+
payloadJson: JSON.stringify({
|
|
210
|
+
...payload,
|
|
211
|
+
agentUnhealthySince: firstDeferredAt,
|
|
212
|
+
agentUnhealthyDeferrals: priorDeferrals + 1,
|
|
213
|
+
}),
|
|
214
|
+
// Reason is already role-prefixed (`reviewer unhealthy: …`).
|
|
215
|
+
intent: (_role, untilLabel) => `${unhealthy.reason} (retrying ${untilLabel})`,
|
|
216
|
+
});
|
|
217
|
+
})();
|
|
218
|
+
}
|
|
182
219
|
export function formatCapEscalationReason(cap, firstDeferredAt) {
|
|
183
220
|
const hours = Math.round(usageCapDeferralCeilingMs() / 3_600_000);
|
|
184
221
|
return `${cap.reason} Work deferred for over ${hours}h (since ${firstDeferredAt}).`;
|
|
@@ -21,11 +21,12 @@ import { parseProfileSnapshot } from "@agent-dealer/shared";
|
|
|
21
21
|
import { buildProfileSnapshot, serializeProfileSnapshot } from "./profile-snapshot.js";
|
|
22
22
|
import { recoverCoordinator } from "./recovery.js";
|
|
23
23
|
import { observeClockJump } from "./clock-jump.js";
|
|
24
|
-
import { admitNext } from "./admission.js";
|
|
24
|
+
import { admitNext, checkRoleAgentHealthy } from "./admission.js";
|
|
25
25
|
import { workerSessionPayload } from "./session-progress.js";
|
|
26
26
|
import { runtimeAvailability } from "../repository/runtime-availability.js";
|
|
27
|
-
import { deferLeasedWorkItemForUsageCap } from "./usage-cap-defer.js";
|
|
27
|
+
import { deferLeasedWorkItemForUsageCap, deferLeasedWorkItemForAgentUnhealthy, } from "./usage-cap-defer.js";
|
|
28
28
|
import { outcomeShouldRecordError, reasonForWorkerFailedEvent } from "./failure-reason.js";
|
|
29
|
+
import { checkAgentDeckHealth } from "../adapters/agent-deck.js";
|
|
29
30
|
const num = (name, dflt) => Number(process.env[name] ?? dflt);
|
|
30
31
|
export const coordinatorConfig = {
|
|
31
32
|
get maxConcurrency() {
|
|
@@ -167,6 +168,9 @@ async function processWorkItem(claimed) {
|
|
|
167
168
|
// leaves a running session that recovery (which keys off work_items) cannot locate. The
|
|
168
169
|
// bind is fenced on the lease token: if this attempt lost its lease between claim and
|
|
169
170
|
// here, the transaction rolls back (session creation undone) and the attempt is dropped.
|
|
171
|
+
//
|
|
172
|
+
// Session setup stays synchronous until the first await below so `runCoordinatorTick`
|
|
173
|
+
// callers (and abort fences) still see a bound running session when started ≥ 1.
|
|
170
174
|
let session;
|
|
171
175
|
try {
|
|
172
176
|
session = getDb().transaction(() => {
|
|
@@ -210,6 +214,20 @@ async function processWorkItem(claimed) {
|
|
|
210
214
|
console.error("[coordinator] session setup", claimed.id, err);
|
|
211
215
|
return; // lost the lease — recovery will reprocess the item when the lease expires
|
|
212
216
|
}
|
|
217
|
+
// NOT-156: fail-closed for the role about to spawn only. Reviewer health must not have
|
|
218
|
+
// blocked developer admit; once a reviewer item is leased, park here (after bind, before
|
|
219
|
+
// the effect) instead of crashing into a deck/CLI failure loop. Checked after session
|
|
220
|
+
// bind so an await does not leave a leased item with no session for abort to fence.
|
|
221
|
+
{
|
|
222
|
+
const deckOnline = await checkAgentDeckHealth();
|
|
223
|
+
const health = await checkRoleAgentHealthy(issue, role, { deckOnline });
|
|
224
|
+
if (!health.ok) {
|
|
225
|
+
const live = getWorkItem(claimed.id) ?? claimed;
|
|
226
|
+
deferLeasedWorkItemForAgentUnhealthy(live, leaseToken, { kind: "agent_unhealthy", reason: health.reason }, issue, instance);
|
|
227
|
+
safeCompleteSession(session.id, "cancelled", { reason: health.reason });
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
213
231
|
const controller = new AbortController();
|
|
214
232
|
const heartbeat = setInterval(() => {
|
|
215
233
|
if (refreshHeartbeat(claimed.id, leaseToken, { leaseMs: coordinatorConfig.leaseMs })) {
|
|
@@ -17,7 +17,7 @@ const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agen
|
|
|
17
17
|
const { createIssue, getIssue } = await import("../repository/issues.js");
|
|
18
18
|
const { getActiveWorkflowInstance, listWorkflowEventsForIssue } = await import("../repository/workflow-events.js");
|
|
19
19
|
const { listHumanActionsForIssue } = await import("../repository/human-actions.js");
|
|
20
|
-
const { listWorkerSessionsForIssue, createWorkerSession, startSession } = await import("../repository/worker-sessions.js");
|
|
20
|
+
const { listWorkerSessionsForIssue, createWorkerSession, startSession, getWorkerSession } = await import("../repository/worker-sessions.js");
|
|
21
21
|
const { listWorkItemsForIssue, claimWorkItem, getWorkItem, bindWorkItemSession } = await import("../repository/work-items.js");
|
|
22
22
|
const { startWorkflow, applyCompletion, resolveHumanActionAndAdvanceAsync } = await import("./commands.js");
|
|
23
23
|
const { registerEffectHandler, resetEffectHandlers } = await import("./effect-registry.js");
|
|
@@ -26,6 +26,7 @@ const { recoverCoordinator } = await import("./recovery.js");
|
|
|
26
26
|
const { ReviewerResult } = await import("./reviewer-result.js");
|
|
27
27
|
const { setMergePrForTests, clearFinalizeInflightForTests } = await import("./auto-merge.js");
|
|
28
28
|
const { stubManagedCloneForTests } = await import("../adapters/managed-repo.js");
|
|
29
|
+
const { setAdmissionHealthCheckerForTests } = await import("./admission.js");
|
|
29
30
|
before(() => migrate());
|
|
30
31
|
// claimWorkItem / recovery scan the whole table (one loop in production); start each
|
|
31
32
|
// case from an empty queue so a prior test's un-processed item is never claimed here.
|
|
@@ -34,8 +35,12 @@ beforeEach(() => {
|
|
|
34
35
|
clearFinalizeInflightForTests();
|
|
35
36
|
setMergePrForTests(async () => ({ ok: true }));
|
|
36
37
|
stubManagedCloneForTests("acme/app");
|
|
38
|
+
setAdmissionHealthCheckerForTests(async () => ({ ok: true }));
|
|
39
|
+
});
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
resetEffectHandlers();
|
|
42
|
+
setAdmissionHealthCheckerForTests(null);
|
|
37
43
|
});
|
|
38
|
-
afterEach(() => resetEffectHandlers());
|
|
39
44
|
function newIssue(maxReviewRounds = 3) {
|
|
40
45
|
return createIssue({
|
|
41
46
|
title: "Loop me",
|
|
@@ -327,3 +332,51 @@ test("placeholder handlers escalate rather than fabricating a PR", async () => {
|
|
|
327
332
|
assert.equal(getIssue(issueId).status, "needs_human");
|
|
328
333
|
assert.equal(listHumanActionsForIssue(issueId).find((a) => a.status === "open").actionType, "policy_escalation");
|
|
329
334
|
});
|
|
335
|
+
// NOT-156: after developer handoff, an unhealthy reviewer parks (explicit reason) instead of
|
|
336
|
+
// spawning into a crash loop. Infra / attempt budgets stay untouched.
|
|
337
|
+
test("NOT-156: reviewer unhealthy at review start parks with reviewer wait reason", async () => {
|
|
338
|
+
const prevBackoff = process.env.DECK_OUTAGE_BACKOFF_BASE_MS;
|
|
339
|
+
process.env.DECK_OUTAGE_BACKOFF_BASE_MS = "60000";
|
|
340
|
+
try {
|
|
341
|
+
const issueId = newIssue();
|
|
342
|
+
let reviewerSpawns = 0;
|
|
343
|
+
registerEffectHandler("developer", async () => cleanHandoff());
|
|
344
|
+
registerEffectHandler("reviewer", async () => {
|
|
345
|
+
reviewerSpawns++;
|
|
346
|
+
return approvedVerdict;
|
|
347
|
+
});
|
|
348
|
+
// Developer round is healthy; only the reviewer role fails health when lease time comes.
|
|
349
|
+
setAdmissionHealthCheckerForTests(async (_agent, role) => {
|
|
350
|
+
if (role === "reviewer") {
|
|
351
|
+
return {
|
|
352
|
+
ok: false,
|
|
353
|
+
reason: `reviewer unhealthy: ${_agent.name} — Run agent-deck setup --client claude --start (Claude MCP not registered)`,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
return { ok: true };
|
|
357
|
+
});
|
|
358
|
+
startWorkflow(issueId);
|
|
359
|
+
await pump(5);
|
|
360
|
+
assert.equal(getIssue(issueId).status, "reviewing");
|
|
361
|
+
assert.equal(reviewerSpawns, 0, "unhealthy reviewer must not spawn");
|
|
362
|
+
assert.match(getIssue(issueId).currentIntent ?? "", /reviewer unhealthy/);
|
|
363
|
+
assert.match(getIssue(issueId).currentIntent ?? "", /MCP not registered/);
|
|
364
|
+
const reviewerItem = listWorkItemsForIssue(issueId).find((w) => w.kind === "reviewer");
|
|
365
|
+
assert.ok(reviewerItem);
|
|
366
|
+
assert.equal(reviewerItem.status, "pending");
|
|
367
|
+
assert.equal(reviewerItem.attemptCount, 0, "health park must not spend attempt budget");
|
|
368
|
+
assert.ok(Date.parse(reviewerItem.availableAt) > Date.now());
|
|
369
|
+
// Session is bound before the health await (abort fence), then cancelled on park —
|
|
370
|
+
// never left running, and the effect must not have run.
|
|
371
|
+
assert.ok(reviewerItem.workerSessionId, "park after bind still records the session");
|
|
372
|
+
assert.equal(getWorkerSession(reviewerItem.workerSessionId).status, "cancelled");
|
|
373
|
+
assert.ok(listWorkflowEventsForIssue(issueId).some((e) => e.type === "worker.deferred"), "timeline must record the park");
|
|
374
|
+
assert.equal(listHumanActionsForIssue(issueId).filter((a) => a.status === "open").length, 0, "must not open a human gate for a recoverable health wait");
|
|
375
|
+
}
|
|
376
|
+
finally {
|
|
377
|
+
if (prevBackoff === undefined)
|
|
378
|
+
delete process.env.DECK_OUTAGE_BACKOFF_BASE_MS;
|
|
379
|
+
else
|
|
380
|
+
process.env.DECK_OUTAGE_BACKOFF_BASE_MS = prevBackoff;
|
|
381
|
+
}
|
|
382
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-dealer/server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"files": [
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"typecheck": "tsc --noEmit"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@agent-dealer/shared": "1.0.
|
|
25
|
+
"@agent-dealer/shared": "1.0.4",
|
|
26
26
|
"@fastify/cors": "^11.0.1",
|
|
27
27
|
"@fastify/static": "^8.2.0",
|
|
28
28
|
"@modelcontextprotocol/sdk": "^1.29.0",
|