agent-dealer 1.0.2 → 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 +175 -51
- package/bundle/server/dist/adapters/agent-health.test.js +338 -210
- package/bundle/server/dist/adapters/linear-graphql.js +200 -6
- package/bundle/server/dist/adapters/linear-graphql.test.js +91 -1
- package/bundle/server/dist/coordinator/admission.js +41 -30
- package/bundle/server/dist/coordinator/admission.test.js +92 -1
- package/bundle/server/dist/coordinator/branch-tip-status.js +120 -0
- package/bundle/server/dist/coordinator/branch-tip-status.test.js +189 -0
- package/bundle/server/dist/coordinator/commands.js +6 -2
- package/bundle/server/dist/coordinator/developer-effect.js +76 -12
- package/bundle/server/dist/coordinator/developer-effect.test.js +20 -0
- package/bundle/server/dist/coordinator/projection.test.js +26 -0
- package/bundle/server/dist/coordinator/prompts.js +4 -1
- package/bundle/server/dist/coordinator/prompts.test.js +12 -0
- package/bundle/server/dist/coordinator/routing.js +43 -2
- package/bundle/server/dist/coordinator/routing.test.js +51 -0
- 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/dist/coordinator/workflows/dev-reviewer-v1.js +8 -0
- package/bundle/server/dist/coordinator/workflows/registry.js +14 -0
- package/bundle/server/dist/coordinator/workflows/registry.test.js +28 -0
- package/bundle/server/dist/coordinator/workflows/types.js +1 -0
- package/bundle/server/dist/index.js +2 -0
- package/bundle/server/dist/routes/index.js +3 -0
- package/bundle/server/dist/routes/issues.js +10 -1
- package/bundle/server/dist/routes/issues.test.js +42 -0
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/{index-hXICi1rX.css → index-CYZRXBZj.css} +1 -1
- package/bundle/server/static-ui/assets/index-DXTVo_vz.js +60 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/package.json +1 -1
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-0kT1vk6L.js +0 -60
|
@@ -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",
|
|
@@ -21,23 +21,63 @@ function capHealthIssues(runtime) {
|
|
|
21
21
|
];
|
|
22
22
|
}
|
|
23
23
|
const RUNTIME_CACHE_MS = 60_000;
|
|
24
|
+
/** Soft probe failures must not stick for the full health TTL — sleep/wake flakes recover on the next tick. */
|
|
25
|
+
const SOFT_PROBE_CACHE_MS = 15_000;
|
|
26
|
+
const DEFAULT_PROBE_TIMEOUT_MS = 8000;
|
|
27
|
+
/** After the first soft fail, retry with these delays before publishing unconfirmed auth. */
|
|
28
|
+
const DEFAULT_SOFT_RETRY_BACKOFFS_MS = [250, 750];
|
|
29
|
+
/** Require this many consecutive soft-fail rounds (each round already retried) before flipping healthy→unhealthy. */
|
|
30
|
+
const SOFT_FAIL_STREAK_TO_UNHEALTHY = 2;
|
|
31
|
+
/** Hold a recent healthy result across a single soft-fail streak after host sleep. */
|
|
32
|
+
const HEALTHY_GRACE_MS = 5 * 60_000;
|
|
24
33
|
const runtimeIssueCache = new Map();
|
|
25
34
|
let githubIssueCache = null;
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
35
|
+
/** Cursor soft-fail streak across health ticks (NOT-157). Reset on hard auth or success. */
|
|
36
|
+
let cursorSoftFailStreak = 0;
|
|
37
|
+
let cursorLastHealthyAt = null;
|
|
38
|
+
let probeTimeoutMsForTests = null;
|
|
39
|
+
let softRetryBackoffsMsForTests = null;
|
|
40
|
+
/**
|
|
41
|
+
* Shorten probe timeout / retry backoff in unit tests so sleep-stub scenarios stay fast.
|
|
42
|
+
* Pass `null` to restore production defaults.
|
|
43
|
+
*/
|
|
44
|
+
export function setCursorProbeTimingForTests(opts) {
|
|
45
|
+
if (opts == null) {
|
|
46
|
+
probeTimeoutMsForTests = null;
|
|
47
|
+
softRetryBackoffsMsForTests = null;
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
probeTimeoutMsForTests = opts.timeoutMs === undefined ? probeTimeoutMsForTests : opts.timeoutMs;
|
|
51
|
+
softRetryBackoffsMsForTests =
|
|
52
|
+
opts.retryBackoffsMs === undefined ? softRetryBackoffsMsForTests : opts.retryBackoffsMs;
|
|
53
|
+
}
|
|
54
|
+
function probeTimeoutMs() {
|
|
55
|
+
return probeTimeoutMsForTests ?? DEFAULT_PROBE_TIMEOUT_MS;
|
|
30
56
|
}
|
|
31
|
-
function
|
|
57
|
+
function softRetryBackoffsMs() {
|
|
58
|
+
return softRetryBackoffsMsForTests ?? DEFAULT_SOFT_RETRY_BACKOFFS_MS;
|
|
59
|
+
}
|
|
60
|
+
function sleep(ms) {
|
|
61
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
62
|
+
}
|
|
63
|
+
function defaultRunCommand(cmd, args, timeoutMs = DEFAULT_PROBE_TIMEOUT_MS) {
|
|
32
64
|
return new Promise((resolve) => {
|
|
33
65
|
const child = spawn(cmd, args, {
|
|
34
66
|
stdio: ["ignore", "pipe", "pipe"],
|
|
35
67
|
env: process.env,
|
|
36
68
|
});
|
|
37
69
|
let output = "";
|
|
70
|
+
let settled = false;
|
|
71
|
+
const finish = (result) => {
|
|
72
|
+
if (settled)
|
|
73
|
+
return;
|
|
74
|
+
settled = true;
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
resolve(result);
|
|
77
|
+
};
|
|
38
78
|
const timer = setTimeout(() => {
|
|
39
79
|
child.kill("SIGTERM");
|
|
40
|
-
|
|
80
|
+
finish({ ok: false, output: output || "timeout", timedOut: true });
|
|
41
81
|
}, timeoutMs);
|
|
42
82
|
child.stdout?.on("data", (d) => {
|
|
43
83
|
output += d.toString();
|
|
@@ -46,15 +86,102 @@ function runCommand(cmd, args, timeoutMs = 8000) {
|
|
|
46
86
|
output += d.toString();
|
|
47
87
|
});
|
|
48
88
|
child.on("error", (err) => {
|
|
49
|
-
|
|
50
|
-
resolve({ ok: false, output: err.message });
|
|
89
|
+
finish({ ok: false, output: err.message, timedOut: false });
|
|
51
90
|
});
|
|
52
91
|
child.on("close", (code) => {
|
|
53
|
-
|
|
54
|
-
resolve({ ok: code === 0, output });
|
|
92
|
+
finish({ ok: code === 0, output, timedOut: false });
|
|
55
93
|
});
|
|
56
94
|
});
|
|
57
95
|
}
|
|
96
|
+
let runCommandImpl = defaultRunCommand;
|
|
97
|
+
/**
|
|
98
|
+
* Replace the process spawner in unit tests (sequence injection for soft-fail / timeout).
|
|
99
|
+
* Pass `null` to restore the real spawner.
|
|
100
|
+
*/
|
|
101
|
+
export function setRunCommandForTests(fn) {
|
|
102
|
+
runCommandImpl = fn ?? defaultRunCommand;
|
|
103
|
+
}
|
|
104
|
+
function runCommand(cmd, args, timeoutMs = DEFAULT_PROBE_TIMEOUT_MS) {
|
|
105
|
+
return runCommandImpl(cmd, args, timeoutMs);
|
|
106
|
+
}
|
|
107
|
+
/** Exported for tests — clears the shared github + runtime health caches and soft-fail streak. */
|
|
108
|
+
export function clearAgentHealthCaches() {
|
|
109
|
+
runtimeIssueCache.clear();
|
|
110
|
+
githubIssueCache = null;
|
|
111
|
+
cursorSoftFailStreak = 0;
|
|
112
|
+
cursorLastHealthyAt = null;
|
|
113
|
+
}
|
|
114
|
+
function isSoftCursorProbeIssue(issue) {
|
|
115
|
+
return (issue.code === "runtime_auth" &&
|
|
116
|
+
(/probe timed out/i.test(issue.message) || /probe failed/i.test(issue.message)));
|
|
117
|
+
}
|
|
118
|
+
function softCursorProbeIssue(result) {
|
|
119
|
+
if (result.timedOut || result.output.trim() === "timeout") {
|
|
120
|
+
return {
|
|
121
|
+
code: "runtime_auth",
|
|
122
|
+
message: "Could not confirm Cursor auth — `cursor-agent status` probe timed out",
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const detail = result.output.trim().split("\n").slice(-1)[0] ?? "no output";
|
|
126
|
+
return {
|
|
127
|
+
code: "runtime_auth",
|
|
128
|
+
message: `Could not confirm Cursor auth — \`cursor-agent status\` probe failed (${detail})`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* NOT-157: classified logged-out / keychain is a hard fail (immediate). Probe timeout /
|
|
133
|
+
* unclassified non-zero exit is soft — retry with backoff, and do not flip a recent healthy
|
|
134
|
+
* result on a single soft-fail streak after host sleep.
|
|
135
|
+
*/
|
|
136
|
+
async function cursorRuntimeIssues() {
|
|
137
|
+
const backoffs = softRetryBackoffsMs();
|
|
138
|
+
const attempts = 1 + backoffs.length;
|
|
139
|
+
let last = { ok: false, output: "no probe", timedOut: false };
|
|
140
|
+
for (let i = 0; i < attempts; i++) {
|
|
141
|
+
if (i > 0)
|
|
142
|
+
await sleep(backoffs[i - 1]);
|
|
143
|
+
last = await runCommand(resolveCursorBin(), cursorInvokeArgs(["status"]), probeTimeoutMs());
|
|
144
|
+
// A failed *spawn* resolves with the error message as its output (`spawn cursor-agent
|
|
145
|
+
// ENOENT`), not with empty output — so an absent binary must be recognised here or it
|
|
146
|
+
// falls through to the unconfirmed-auth branch below and names the wrong remedy.
|
|
147
|
+
if (!last.ok && (/\bENOENT\b/.test(last.output) || (!last.output.trim() && !cursorBinExists()))) {
|
|
148
|
+
cursorSoftFailStreak = 0;
|
|
149
|
+
cursorLastHealthyAt = null;
|
|
150
|
+
return [
|
|
151
|
+
{
|
|
152
|
+
code: "cli_missing",
|
|
153
|
+
message: "cursor-agent not found — run: curl https://cursor.com/install -fsS | bash",
|
|
154
|
+
},
|
|
155
|
+
];
|
|
156
|
+
}
|
|
157
|
+
const authIssue = cursorAuthIssueFromOutput(last.output);
|
|
158
|
+
if (authIssue) {
|
|
159
|
+
cursorSoftFailStreak = 0;
|
|
160
|
+
cursorLastHealthyAt = null;
|
|
161
|
+
console.warn(`[agent-health] cursor-agent status: classified auth failure (${authIssue.code})`);
|
|
162
|
+
return [authIssue];
|
|
163
|
+
}
|
|
164
|
+
if (last.ok) {
|
|
165
|
+
cursorSoftFailStreak = 0;
|
|
166
|
+
cursorLastHealthyAt = Date.now();
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
// Soft fail this attempt — try again before publishing.
|
|
170
|
+
const kind = last.timedOut || last.output.trim() === "timeout" ? "timed out" : "failed";
|
|
171
|
+
console.warn(`[agent-health] cursor-agent status probe ${kind} (attempt ${i + 1}/${attempts})`);
|
|
172
|
+
}
|
|
173
|
+
// Retries exhausted with only soft failures.
|
|
174
|
+
cursorSoftFailStreak += 1;
|
|
175
|
+
const softIssue = softCursorProbeIssue(last);
|
|
176
|
+
const kind = last.timedOut || last.output.trim() === "timeout" ? "timed out" : "failed";
|
|
177
|
+
console.warn(`[agent-health] cursor-agent status probe ${kind} after retries (streak=${cursorSoftFailStreak})`);
|
|
178
|
+
const withinGrace = cursorLastHealthyAt != null && Date.now() - cursorLastHealthyAt < HEALTHY_GRACE_MS;
|
|
179
|
+
if (withinGrace && cursorSoftFailStreak < SOFT_FAIL_STREAK_TO_UNHEALTHY) {
|
|
180
|
+
// Hold the recent healthy result — one post-sleep timeout must not park the queue.
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
return [softIssue];
|
|
184
|
+
}
|
|
58
185
|
/** Exported for direct testing — bypasses the 60s cache in runtimeIssues(). */
|
|
59
186
|
export async function runtimeIssuesUncached(runtime) {
|
|
60
187
|
const issues = [];
|
|
@@ -107,34 +234,10 @@ export async function runtimeIssuesUncached(runtime) {
|
|
|
107
234
|
}
|
|
108
235
|
return issues;
|
|
109
236
|
}
|
|
110
|
-
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
if (!status.ok && (/\bENOENT\b/.test(status.output) || (!status.output.trim() && !cursorBinExists()))) {
|
|
115
|
-
issues.push({ code: "cli_missing", message: "cursor-agent not found — run: curl https://cursor.com/install -fsS | bash" });
|
|
116
|
-
return issues;
|
|
117
|
-
}
|
|
118
|
-
const authIssue = cursorAuthIssueFromOutput(status.output);
|
|
119
|
-
if (authIssue) {
|
|
120
|
-
issues.push(authIssue);
|
|
121
|
-
return issues;
|
|
122
|
-
}
|
|
123
|
-
// NOT-133: an unclassified *failure* of the probe itself (non-zero exit, timeout) used to
|
|
124
|
-
// be read as "healthy" and admitted the agent. Silence is not evidence of auth, so this
|
|
125
|
-
// fails closed: the agent stays unhealthy — and its issues stay queued — until the probe
|
|
126
|
-
// succeeds. That is a deliberate trade against the incident, where admitting on a guess
|
|
127
|
-
// cost 12 dead sessions and parked three issues on a human. Reported as runtime_auth so
|
|
128
|
-
// the existing agents-page CLI status renders it, with a message that says plainly the
|
|
129
|
-
// state is unconfirmed rather than asserting the agent is logged out.
|
|
130
|
-
if (!status.ok) {
|
|
131
|
-
const detail = status.output.trim().split("\n").slice(-1)[0] ?? "no output";
|
|
132
|
-
issues.push({
|
|
133
|
-
code: "runtime_auth",
|
|
134
|
-
message: `Could not confirm Cursor auth — \`cursor-agent status\` failed (${detail})`,
|
|
135
|
-
});
|
|
136
|
-
}
|
|
137
|
-
return issues;
|
|
237
|
+
// NOT-157: Cursor auth probe distinguishes hard (classified logged-out / keychain) from
|
|
238
|
+
// soft (timeout / unclassified probe flake). Soft path retries with backoff and holds a
|
|
239
|
+
// recent healthy result across a single post-sleep streak.
|
|
240
|
+
return cursorRuntimeIssues();
|
|
138
241
|
}
|
|
139
242
|
/**
|
|
140
243
|
* Bedrock/Vertex installs authenticate through AWS/GCP credentials instead of a Claude
|
|
@@ -229,15 +332,28 @@ async function githubIssues() {
|
|
|
229
332
|
async function runtimeIssues(runtime) {
|
|
230
333
|
const capIssues = capHealthIssues(runtime);
|
|
231
334
|
const cached = runtimeIssueCache.get(runtime);
|
|
232
|
-
|
|
335
|
+
const ttl = cached?.softProbeFailure ? SOFT_PROBE_CACHE_MS : RUNTIME_CACHE_MS;
|
|
336
|
+
if (cached && Date.now() - cached.at < ttl) {
|
|
233
337
|
return [...capIssues, ...cached.issues];
|
|
234
338
|
}
|
|
235
339
|
const issues = await runtimeIssuesUncached(runtime);
|
|
236
340
|
const nonCap = issues.filter((i) => i.code !== "usage_capped");
|
|
237
|
-
|
|
341
|
+
// Soft fail (published or grace-held) uses a short TTL so a wake retry can clear quickly;
|
|
342
|
+
// a sticky 60s cache of "Could not confirm" is what parked the queue after sleep (NOT-157).
|
|
343
|
+
const softProbeFailure = nonCap.some(isSoftCursorProbeIssue) ||
|
|
344
|
+
(runtime === "cursor_local" && cursorSoftFailStreak > 0);
|
|
345
|
+
runtimeIssueCache.set(runtime, { at: Date.now(), issues: nonCap, softProbeFailure });
|
|
238
346
|
return [...capIssues, ...nonCap];
|
|
239
347
|
}
|
|
240
|
-
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) {
|
|
241
357
|
const issues = [];
|
|
242
358
|
if (!agent.deckId) {
|
|
243
359
|
issues.push({
|
|
@@ -264,11 +380,19 @@ function agentSpecificIssues(agent, agentDeckOnline, mcpRegistered, deckAccessRe
|
|
|
264
380
|
});
|
|
265
381
|
}
|
|
266
382
|
}
|
|
267
|
-
if (agent.runtime === "claude_code" && agentDeckOnline
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
+
}
|
|
272
396
|
}
|
|
273
397
|
return issues;
|
|
274
398
|
}
|
|
@@ -276,12 +400,12 @@ export async function healthForAgent(agent, agentDeckOnline, runtimeIssuesByRunt
|
|
|
276
400
|
const runtime = runtimeIssuesByRuntime !== undefined
|
|
277
401
|
? (runtimeIssuesByRuntime.get(agent.runtime) ?? [])
|
|
278
402
|
: await runtimeIssues(agent.runtime);
|
|
279
|
-
const
|
|
403
|
+
const mcpRegistration = resolveMcpRegistration(mcpRegistered);
|
|
280
404
|
const github = githubIssuesList ?? (await githubIssues());
|
|
281
405
|
const issues = [
|
|
282
406
|
...runtime,
|
|
283
407
|
...github,
|
|
284
|
-
...agentSpecificIssues(agent, agentDeckOnline,
|
|
408
|
+
...agentSpecificIssues(agent, agentDeckOnline, mcpRegistration, deckAccessResult),
|
|
285
409
|
];
|
|
286
410
|
return {
|
|
287
411
|
...agent,
|
|
@@ -291,7 +415,7 @@ export async function healthForAgent(agent, agentDeckOnline, runtimeIssuesByRunt
|
|
|
291
415
|
}
|
|
292
416
|
export async function listAgentsWithHealth(agents) {
|
|
293
417
|
const agentDeckOnline = await checkAgentDeckHealth();
|
|
294
|
-
const
|
|
418
|
+
const mcpRegistration = checkAgentDeckMcpRegistration();
|
|
295
419
|
const needsDeckAccess = agents.some((a) => a.deckId);
|
|
296
420
|
const deckAccessResult = agentDeckOnline && needsDeckAccess ? await fetchDecks() : null;
|
|
297
421
|
const runtimes = [...new Set(agents.map((a) => a.runtime))];
|
|
@@ -302,5 +426,5 @@ export async function listAgentsWithHealth(agents) {
|
|
|
302
426
|
})),
|
|
303
427
|
githubIssues(),
|
|
304
428
|
]);
|
|
305
|
-
return Promise.all(agents.map((a) => healthForAgent(a, agentDeckOnline, runtimeIssuesByRuntime,
|
|
429
|
+
return Promise.all(agents.map((a) => healthForAgent(a, agentDeckOnline, runtimeIssuesByRuntime, mcpRegistration, deckAccessResult, githubIssuesList)));
|
|
306
430
|
}
|