agent-dealer 1.0.2 → 1.0.3
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-health.js +148 -40
- package/bundle/server/dist/adapters/agent-health.test.js +319 -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.test.js +36 -0
- 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/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
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// (deleted) — must surface a distinct issue. resolveDeckName silently swallows both of
|
|
6
6
|
// the same failures to null elsewhere, so this is the one place an operator can see why
|
|
7
7
|
// deck resolution is broken.
|
|
8
|
-
import { test } from "node:test";
|
|
8
|
+
import { describe, test } from "node:test";
|
|
9
9
|
import assert from "node:assert/strict";
|
|
10
10
|
import fs from "node:fs";
|
|
11
11
|
import os from "node:os";
|
|
@@ -15,223 +15,332 @@ import { fileURLToPath } from "node:url";
|
|
|
15
15
|
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-agenthealth-"));
|
|
16
16
|
const { migrate, getDb } = await import("../db/index.js");
|
|
17
17
|
const { createAgent, getAgent } = await import("../repository/agents.js");
|
|
18
|
-
const { healthForAgent, runtimeIssuesUncached, githubIssuesUncached, clearAgentHealthCaches } = await import("./agent-health.js");
|
|
18
|
+
const { healthForAgent, runtimeIssuesUncached, githubIssuesUncached, clearAgentHealthCaches, setCursorProbeTimingForTests, setRunCommandForTests, } = await import("./agent-health.js");
|
|
19
19
|
migrate();
|
|
20
20
|
const FAILURE = { ok: false, code: "DECK_UNAVAILABLE", message: "Agent Deck API error: 502" };
|
|
21
21
|
/** Tests inject an empty github list so host `gh auth` does not pollute assertions. */
|
|
22
22
|
const NO_GITHUB = [];
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
23
|
+
// Shared inject/cache hooks — must not run concurrently with sibling cases in this file.
|
|
24
|
+
describe("agent-health", { concurrency: false }, () => {
|
|
25
|
+
test("missing deckId: reports deck_missing", async () => {
|
|
26
|
+
const created = createAgent({
|
|
27
|
+
name: "no-deck",
|
|
28
|
+
runtime: "claude_code",
|
|
29
|
+
deckId: "00000000-0000-4000-a000-000000000099",
|
|
30
|
+
});
|
|
31
|
+
getDb().prepare("UPDATE agents SET deck_id = NULL WHERE id = ?").run(created.id);
|
|
32
|
+
const agent = getAgent(created.id);
|
|
33
|
+
const result = await healthForAgent(agent, true, new Map(), true, FAILURE, NO_GITHUB);
|
|
34
|
+
assert.equal(result.issues.some((i) => i.code === "deck_missing"), true);
|
|
35
|
+
assert.equal(result.healthy, false);
|
|
36
|
+
});
|
|
37
|
+
test("agent deck offline: reports deck_offline, not deck_unauthorized", async () => {
|
|
38
|
+
const agent = createAgent({
|
|
39
|
+
name: "offline",
|
|
40
|
+
runtime: "claude_code",
|
|
41
|
+
deckId: randomUUID(),
|
|
42
|
+
});
|
|
43
|
+
const result = await healthForAgent(agent, false, new Map(), true, FAILURE, NO_GITHUB);
|
|
44
|
+
assert.deepEqual(result.issues.map((i) => i.code).sort(), ["deck_offline"]);
|
|
45
|
+
});
|
|
46
|
+
test("agent deck online but metadata unavailable: reports deck_unauthorized with the Deck message", async () => {
|
|
47
|
+
const agent = createAgent({
|
|
48
|
+
name: "unavailable",
|
|
49
|
+
runtime: "claude_code",
|
|
50
|
+
deckId: randomUUID(),
|
|
51
|
+
});
|
|
52
|
+
const result = await healthForAgent(agent, true, new Map(), true, FAILURE, NO_GITHUB);
|
|
53
|
+
const issue = result.issues.find((i) => i.code === "deck_unauthorized");
|
|
54
|
+
assert.ok(issue, "expected a deck_unauthorized issue");
|
|
55
|
+
assert.equal(issue?.message, "Agent Deck API error: 502");
|
|
56
|
+
});
|
|
57
|
+
test("agent deck online, metadata call succeeds, but this deck isn't in the returned set: reports deck_unauthorized", async () => {
|
|
58
|
+
const deckId = randomUUID();
|
|
59
|
+
const agent = createAgent({ name: "stale-deck", runtime: "claude_code", deckId });
|
|
60
|
+
const deckAccessResult = { ok: true, decks: [{ id: randomUUID(), name: "some-other-deck" }] };
|
|
61
|
+
const result = await healthForAgent(agent, true, new Map(), true, deckAccessResult, NO_GITHUB);
|
|
62
|
+
const issue = result.issues.find((i) => i.code === "deck_unauthorized");
|
|
63
|
+
assert.ok(issue, "expected a deck_unauthorized issue for a deck missing from the launch set");
|
|
64
|
+
});
|
|
65
|
+
test("agent deck online and this deck is in the returned set: healthy on the deck axis", async () => {
|
|
66
|
+
const deckId = randomUUID();
|
|
67
|
+
const agent = createAgent({ name: "healthy", runtime: "claude_code", deckId });
|
|
68
|
+
const deckAccessResult = { ok: true, decks: [{ id: deckId, name: "healthy-deck" }] };
|
|
69
|
+
const result = await healthForAgent(agent, true, new Map(), true, deckAccessResult, NO_GITHUB);
|
|
70
|
+
assert.equal(result.issues.some((i) => i.code === "deck_unauthorized" || i.code === "deck_offline"), false);
|
|
71
|
+
});
|
|
72
|
+
test("agent deck online with no deck-access result computed (e.g. no agent needed it): no false positive", async () => {
|
|
73
|
+
const agent = createAgent({
|
|
74
|
+
name: "no-result",
|
|
75
|
+
runtime: "claude_code",
|
|
76
|
+
deckId: randomUUID(),
|
|
77
|
+
});
|
|
78
|
+
const result = await healthForAgent(agent, true, new Map(), true, null, NO_GITHUB);
|
|
79
|
+
assert.equal(result.issues.some((i) => i.code === "deck_unauthorized" || i.code === "deck_offline"), false);
|
|
80
|
+
});
|
|
81
|
+
test("codex_local with a missing CLI reports cli_missing exactly once, not twice", async () => {
|
|
82
|
+
const prevCodexCli = process.env.CODEX_CLI;
|
|
83
|
+
process.env.CODEX_CLI = "/nonexistent/path/codex-does-not-exist";
|
|
84
|
+
try {
|
|
85
|
+
const issues = await runtimeIssuesUncached("codex_local");
|
|
86
|
+
assert.deepEqual(issues.map((i) => i.code), ["cli_missing"]);
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
if (prevCodexCli === undefined)
|
|
90
|
+
delete process.env.CODEX_CLI;
|
|
91
|
+
else
|
|
92
|
+
process.env.CODEX_CLI = prevCodexCli;
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
test("cursor_local with a bound deck: no deck access issue (launch MCP is supported)", async () => {
|
|
96
|
+
const agent = createAgent({
|
|
97
|
+
name: "cursor-with-deck",
|
|
98
|
+
runtime: "cursor_local",
|
|
99
|
+
deckId: randomUUID(),
|
|
100
|
+
});
|
|
101
|
+
const result = await healthForAgent(agent, true, new Map(), true, null, NO_GITHUB);
|
|
102
|
+
assert.equal(result.issues.some((i) => i.code === "deck_unauthorized" || i.code === "deck_offline"), false);
|
|
103
|
+
});
|
|
104
|
+
test("github_auth issues mark the agent unhealthy so Start can refuse before a wasted run", async () => {
|
|
105
|
+
const agent = createAgent({
|
|
106
|
+
name: "needs-gh",
|
|
107
|
+
runtime: "claude_code",
|
|
108
|
+
deckId: "00000000-0000-4000-a000-000000000099",
|
|
109
|
+
});
|
|
110
|
+
const gh = [
|
|
111
|
+
{ code: "github_auth", message: "Run `gh auth login` — GitHub CLI auth required to open PRs" },
|
|
112
|
+
];
|
|
113
|
+
const result = await healthForAgent(agent, true, new Map(), true, null, gh);
|
|
114
|
+
assert.equal(result.healthy, false);
|
|
115
|
+
assert.equal(result.issues.some((i) => i.code === "github_auth"), true);
|
|
116
|
+
});
|
|
117
|
+
test("githubIssuesUncached reports github_auth when gh auth status fails with an invalid token", async () => {
|
|
118
|
+
clearAgentHealthCaches();
|
|
119
|
+
// Exercise the real parser path by stubbing via PATH... we instead unit-test the
|
|
120
|
+
// injected-list path above for healthForAgent. Here we assert the uncached helper
|
|
121
|
+
// returns a known shape when `gh` is present but auth is bad — skip if gh missing.
|
|
122
|
+
const { spawnSync } = await import("node:child_process");
|
|
123
|
+
const probe = spawnSync("gh", ["--version"], { encoding: "utf8" });
|
|
124
|
+
if (probe.error || probe.status !== 0) {
|
|
125
|
+
// No gh on this host — nothing to assert about auth status.
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const issues = await githubIssuesUncached();
|
|
129
|
+
for (const issue of issues) {
|
|
130
|
+
assert.ok(issue.code === "github_auth" || issue.code === "github_cli_missing");
|
|
131
|
+
assert.ok(issue.message.length > 0);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
test("cursor_keychain status/stderr fixture maps to a blocking health issue via cursorAuthIssueFromOutput", async () => {
|
|
135
|
+
const { cursorAuthIssueFromOutput } = await import("@agent-dealer/shared");
|
|
136
|
+
const fixture = `Cursor couldn't save your login to the macOS keychain (errSecDuplicateItem, security exit code 45).
|
|
135
137
|
The keychain item is stuck. Delete it and sign in again:
|
|
136
138
|
security delete-generic-password -s cursor-access-token -a cursor-user
|
|
137
139
|
agent login
|
|
138
140
|
`;
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
});
|
|
151
|
-
// ---------------------------------------------------------------------------
|
|
152
|
-
// NOT-133: the admission preflight classifies each runtime's *captured* logged-out
|
|
153
|
-
// output. Stubs replay the fixtures in packages/shared/src/fixtures/runtime-auth/.
|
|
154
|
-
// ---------------------------------------------------------------------------
|
|
155
|
-
const FIXTURES = path.join(path.dirname(fileURLToPath(import.meta.url)), "../../../shared/src/fixtures/runtime-auth");
|
|
156
|
-
/** A CLI stub that prints a captured fixture verbatim and exits with the captured code. */
|
|
157
|
-
function stubCli(name, fixture, exitCode) {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
async function withEnv(key, value, fn) {
|
|
165
|
-
const prev = process.env[key];
|
|
166
|
-
process.env[key] = value;
|
|
167
|
-
clearAgentHealthCaches();
|
|
168
|
-
try {
|
|
169
|
-
return await fn();
|
|
141
|
+
const issue = cursorAuthIssueFromOutput(fixture);
|
|
142
|
+
assert.equal(issue?.code, "cursor_keychain");
|
|
143
|
+
assert.match(issue.message, /delete-generic-password/);
|
|
144
|
+
const agent = createAgent({
|
|
145
|
+
name: "cursor-keychain",
|
|
146
|
+
runtime: "cursor_local",
|
|
147
|
+
deckId: "00000000-0000-4000-a000-000000000099",
|
|
148
|
+
});
|
|
149
|
+
const result = await healthForAgent(agent, true, new Map([["cursor_local", [issue]]]), true, null, NO_GITHUB);
|
|
150
|
+
assert.equal(result.healthy, false);
|
|
151
|
+
assert.equal(result.issues.some((i) => i.code === "cursor_keychain"), true);
|
|
152
|
+
});
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// NOT-133: the admission preflight classifies each runtime's *captured* logged-out
|
|
155
|
+
// output. Stubs replay the fixtures in packages/shared/src/fixtures/runtime-auth/.
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
const FIXTURES = path.join(path.dirname(fileURLToPath(import.meta.url)), "../../../shared/src/fixtures/runtime-auth");
|
|
158
|
+
/** A CLI stub that prints a captured fixture verbatim and exits with the captured code. */
|
|
159
|
+
function stubCli(name, fixture, exitCode) {
|
|
160
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-cli-stub-"));
|
|
161
|
+
const bin = path.join(dir, name);
|
|
162
|
+
fs.writeFileSync(bin, `#!/bin/sh\ncat ${JSON.stringify(path.join(FIXTURES, fixture))}\nexit ${exitCode}\n`);
|
|
163
|
+
fs.chmodSync(bin, 0o755);
|
|
164
|
+
return bin;
|
|
170
165
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
else
|
|
175
|
-
process.env[key] = prev;
|
|
166
|
+
async function withEnv(key, value, fn) {
|
|
167
|
+
const prev = process.env[key];
|
|
168
|
+
process.env[key] = value;
|
|
176
169
|
clearAgentHealthCaches();
|
|
170
|
+
try {
|
|
171
|
+
return await fn();
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
if (prev === undefined)
|
|
175
|
+
delete process.env[key];
|
|
176
|
+
else
|
|
177
|
+
process.env[key] = prev;
|
|
178
|
+
clearAgentHealthCaches();
|
|
179
|
+
}
|
|
177
180
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
});
|
|
230
|
-
test("
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
});
|
|
181
|
+
test("logged-out `cursor-agent status` capture blocks the cursor runtime (exit 0 — output is the only signal)", async () => {
|
|
182
|
+
const issues = await withEnv("CURSOR_CLI", stubCli("cursor-agent", "cursor-agent-status-logged-out.txt", 0), () => runtimeIssuesUncached("cursor_local"));
|
|
183
|
+
assert.deepEqual(issues.map((i) => i.code), ["runtime_auth"]);
|
|
184
|
+
assert.match(issues[0].message, /cursor-agent login/);
|
|
185
|
+
assert.match(issues[0].message, /CURSOR_API_KEY/);
|
|
186
|
+
});
|
|
187
|
+
test("logged-in `cursor-agent status` capture leaves the cursor runtime healthy", async () => {
|
|
188
|
+
const issues = await withEnv("CURSOR_CLI", stubCli("cursor-agent", "cursor-agent-status-logged-in.txt", 0), () => runtimeIssuesUncached("cursor_local"));
|
|
189
|
+
assert.deepEqual(issues, []);
|
|
190
|
+
});
|
|
191
|
+
test("a cursor status probe that fails without a classifiable reason blocks rather than admits", async () => {
|
|
192
|
+
// NOT-133: silence used to be read as health, so an agent that could not be checked was
|
|
193
|
+
// admitted anyway. Unknown auth must wait, not spend infra attempts on ~1s dead sessions.
|
|
194
|
+
// NOT-157: messaging says "probe failed" (soft) rather than asserting logged-out; still blocks.
|
|
195
|
+
setCursorProbeTimingForTests({ timeoutMs: 2000, retryBackoffsMs: [10] });
|
|
196
|
+
setRunCommandForTests(async () => ({
|
|
197
|
+
ok: false,
|
|
198
|
+
output: "panic: runtime broke\n",
|
|
199
|
+
timedOut: false,
|
|
200
|
+
}));
|
|
201
|
+
clearAgentHealthCaches();
|
|
202
|
+
try {
|
|
203
|
+
const issues = await runtimeIssuesUncached("cursor_local");
|
|
204
|
+
assert.deepEqual(issues.map((i) => i.code), ["runtime_auth"]);
|
|
205
|
+
assert.match(issues[0].message, /Could not confirm Cursor auth/);
|
|
206
|
+
assert.match(issues[0].message, /probe failed/i);
|
|
207
|
+
assert.doesNotMatch(issues[0].message, /not authenticated/i);
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
setRunCommandForTests(null);
|
|
211
|
+
setCursorProbeTimingForTests(null);
|
|
212
|
+
clearAgentHealthCaches();
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
test("logged-out `codex login status` capture blocks the codex runtime", async () => {
|
|
216
|
+
const issues = await withEnv("CODEX_CLI", stubCli("codex", "codex-login-status-logged-out.txt", 1), () => runtimeIssuesUncached("codex_local"));
|
|
217
|
+
assert.deepEqual(issues.map((i) => i.code), ["runtime_auth"]);
|
|
218
|
+
assert.match(issues[0].message, /codex login/);
|
|
219
|
+
});
|
|
220
|
+
test("logged-in `codex login status` capture leaves the codex runtime healthy", async () => {
|
|
221
|
+
const issues = await withEnv("CODEX_CLI", stubCli("codex", "codex-login-status-logged-in.txt", 0), () => runtimeIssuesUncached("codex_local"));
|
|
222
|
+
assert.deepEqual(issues, []);
|
|
223
|
+
});
|
|
224
|
+
test("a cursor probe that cannot spawn reports the missing CLI, not unconfirmed auth", async () => {
|
|
225
|
+
// A spawn failure resolves with `spawn <bin> ENOENT` as its *output*, so the
|
|
226
|
+
// empty-output test for a missing binary never fires and the operator would be told to
|
|
227
|
+
// check their login when the binary is what is absent.
|
|
228
|
+
const missing = path.join(os.tmpdir(), `dealer-absent-cursor-agent-${randomUUID()}`);
|
|
229
|
+
const issues = await withEnv("CURSOR_CLI", missing, () => runtimeIssuesUncached("cursor_local"));
|
|
230
|
+
assert.deepEqual(issues.map((i) => i.code), ["cli_missing"]);
|
|
231
|
+
assert.match(issues[0].message, /cursor\.com\/install/);
|
|
232
|
+
});
|
|
233
|
+
test("logged-out `claude auth status` capture blocks the claude runtime", async () => {
|
|
234
|
+
// Claude had no auth preflight at all before NOT-133 — a logged-out Claude agent was
|
|
235
|
+
// admitted exactly the way the logged-out Cursor ones were. Exit 1 with a JSON body: the
|
|
236
|
+
// classifier reads `"loggedIn": false`, never the status code.
|
|
237
|
+
const issues = await withEnv("CLAUDE_CLI", stubCli("claude", "claude-auth-status-logged-out.txt", 1), () => runtimeIssuesUncached("claude_code"));
|
|
238
|
+
assert.deepEqual(issues.map((i) => i.code), ["runtime_auth"]);
|
|
239
|
+
assert.match(issues[0].message, /claude auth login/);
|
|
240
|
+
});
|
|
241
|
+
test("logged-in `claude auth status` capture leaves the claude runtime healthy", async () => {
|
|
242
|
+
const issues = await withEnv("CLAUDE_CLI", stubCli("claude", "claude-auth-status-logged-in.txt", 0), () => runtimeIssuesUncached("claude_code"));
|
|
243
|
+
assert.deepEqual(issues, []);
|
|
244
|
+
});
|
|
245
|
+
test("an older claude CLI without `auth status` is not a false block", async () => {
|
|
246
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-cli-stub-"));
|
|
247
|
+
const bin = path.join(dir, "claude");
|
|
248
|
+
fs.writeFileSync(bin, "#!/bin/sh\necho \"error: unknown command 'auth'\" >&2\nexit 1\n");
|
|
249
|
+
fs.chmodSync(bin, 0o755);
|
|
250
|
+
const issues = await withEnv("CLAUDE_CLI", bin, () => runtimeIssuesUncached("claude_code"));
|
|
251
|
+
assert.deepEqual(issues, []);
|
|
252
|
+
});
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
// NOT-157: sleep/wake probe timeout is a soft fail — retry + grace — not a
|
|
255
|
+
// sticky "not authenticated" park of the queue.
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
test("NOT-157: timeout then success within the soft window leaves the cursor runtime healthy", async () => {
|
|
258
|
+
// First status times out; the in-probe retry returns the logged-in capture.
|
|
259
|
+
const loggedIn = fs.readFileSync(path.join(FIXTURES, "cursor-agent-status-logged-in.txt"), "utf8");
|
|
260
|
+
let calls = 0;
|
|
261
|
+
setCursorProbeTimingForTests({ timeoutMs: 200, retryBackoffsMs: [10] });
|
|
262
|
+
setRunCommandForTests(async () => {
|
|
263
|
+
calls += 1;
|
|
264
|
+
if (calls === 1)
|
|
265
|
+
return { ok: false, output: "timeout", timedOut: true };
|
|
266
|
+
return { ok: true, output: loggedIn, timedOut: false };
|
|
267
|
+
});
|
|
268
|
+
clearAgentHealthCaches();
|
|
269
|
+
try {
|
|
270
|
+
const issues = await runtimeIssuesUncached("cursor_local");
|
|
271
|
+
assert.deepEqual(issues, []);
|
|
272
|
+
assert.equal(calls, 2);
|
|
273
|
+
}
|
|
274
|
+
finally {
|
|
275
|
+
setRunCommandForTests(null);
|
|
276
|
+
setCursorProbeTimingForTests(null);
|
|
277
|
+
clearAgentHealthCaches();
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
test("NOT-157: soft probe timeout message names probe timeout, not 'not authenticated'", async () => {
|
|
281
|
+
// Always time out → soft fail after retries. No prior healthy → fail closed, but the
|
|
282
|
+
// wait_reason / Ops copy must say the probe timed out (not that Cursor is logged out).
|
|
283
|
+
setCursorProbeTimingForTests({ timeoutMs: 150, retryBackoffsMs: [10] });
|
|
284
|
+
setRunCommandForTests(async () => ({ ok: false, output: "timeout", timedOut: true }));
|
|
285
|
+
clearAgentHealthCaches();
|
|
286
|
+
try {
|
|
287
|
+
const issues = await runtimeIssuesUncached("cursor_local");
|
|
288
|
+
assert.deepEqual(issues.map((i) => i.code), ["runtime_auth"]);
|
|
289
|
+
assert.match(issues[0].message, /probe timed out/i);
|
|
290
|
+
assert.doesNotMatch(issues[0].message, /not authenticated/i);
|
|
291
|
+
}
|
|
292
|
+
finally {
|
|
293
|
+
setRunCommandForTests(null);
|
|
294
|
+
setCursorProbeTimingForTests(null);
|
|
295
|
+
clearAgentHealthCaches();
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
test("NOT-157: a single soft timeout after a recent healthy probe does not flip unhealthy", async () => {
|
|
299
|
+
const loggedIn = fs.readFileSync(path.join(FIXTURES, "cursor-agent-status-logged-in.txt"), "utf8");
|
|
300
|
+
let phase = "healthy";
|
|
301
|
+
setCursorProbeTimingForTests({ timeoutMs: 150, retryBackoffsMs: [10] });
|
|
302
|
+
setRunCommandForTests(async () => {
|
|
303
|
+
if (phase === "healthy")
|
|
304
|
+
return { ok: true, output: loggedIn, timedOut: false };
|
|
305
|
+
return { ok: false, output: "timeout", timedOut: true };
|
|
306
|
+
});
|
|
307
|
+
clearAgentHealthCaches();
|
|
308
|
+
try {
|
|
309
|
+
const healthy = await runtimeIssuesUncached("cursor_local");
|
|
310
|
+
assert.deepEqual(healthy, []);
|
|
311
|
+
phase = "hang";
|
|
312
|
+
// One soft-fail streak (timeouts on every attempt in this call) while still inside
|
|
313
|
+
// the healthy grace window must not publish runtime_auth.
|
|
314
|
+
const afterSoft = await runtimeIssuesUncached("cursor_local");
|
|
315
|
+
assert.deepEqual(afterSoft, []);
|
|
316
|
+
}
|
|
317
|
+
finally {
|
|
318
|
+
setRunCommandForTests(null);
|
|
319
|
+
setCursorProbeTimingForTests(null);
|
|
320
|
+
clearAgentHealthCaches();
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
test("NOT-157: verbatim logged-out capture still blocks immediately (hard fail)", async () => {
|
|
324
|
+
// Hard classification must not wait for soft retries — NOT-133 preserved.
|
|
325
|
+
const loggedOut = fs.readFileSync(path.join(FIXTURES, "cursor-agent-status-logged-out.txt"), "utf8");
|
|
326
|
+
let calls = 0;
|
|
327
|
+
setCursorProbeTimingForTests({ timeoutMs: 8000, retryBackoffsMs: [500, 500] });
|
|
328
|
+
setRunCommandForTests(async () => {
|
|
329
|
+
calls += 1;
|
|
330
|
+
return { ok: true, output: loggedOut, timedOut: false };
|
|
331
|
+
});
|
|
332
|
+
clearAgentHealthCaches();
|
|
333
|
+
try {
|
|
334
|
+
const issues = await runtimeIssuesUncached("cursor_local");
|
|
335
|
+
assert.deepEqual(issues.map((i) => i.code), ["runtime_auth"]);
|
|
336
|
+
assert.match(issues[0].message, /not authenticated/i);
|
|
337
|
+
assert.match(issues[0].message, /cursor-agent login/);
|
|
338
|
+
assert.equal(calls, 1);
|
|
339
|
+
}
|
|
340
|
+
finally {
|
|
341
|
+
setRunCommandForTests(null);
|
|
342
|
+
setCursorProbeTimingForTests(null);
|
|
343
|
+
clearAgentHealthCaches();
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
}); // describe agent-health (serial)
|