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.
@@ -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
- export function isAgentDeckMcpRegistered() {
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 false;
185
+ return { status: "missing" };
162
186
  const raw = fs.readFileSync(configPath, "utf8");
163
187
  const config = JSON.parse(raw);
164
- const expected = new URL(getAgentDeckMcpUrl().replace(/\/mcp\/?$/, "") + "/mcp");
188
+ const expected = expectedAgentDeckMcpEndpoint();
189
+ let mismatch = null;
165
190
  for (const [name, server] of Object.entries(config.mcpServers ?? {})) {
166
- if (!name.toLowerCase().includes("agent-deck") && name !== "agent-deck")
191
+ if (!isAgentDeckMcpServerName(name))
167
192
  continue;
168
- if (!server.url)
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
- catch {
176
- // skip invalid url
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
- return false;
229
+ if (mismatch)
230
+ return mismatch;
231
+ return { status: "missing" };
180
232
  }
181
233
  catch {
182
- return false;
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, isAgentDeckMcpRegistered } from "./agent-deck.js";
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 agentSpecificIssues(agent, agentDeckOnline, mcpRegistered, deckAccessResult) {
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 && !mcpRegistered) {
376
- issues.push({
377
- code: "mcp_not_registered",
378
- message: "Run agent-deck setup --client claude --start (Claude MCP not registered)",
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 deckMcpOk = mcpRegistered ?? isAgentDeckMcpRegistered();
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, deckMcpOk, deckAccessResult),
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 mcpRegistered = isAgentDeckMcpRegistered();
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, mcpRegistered, deckAccessResult, githubIssuesList)));
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 does not hit real CLIs. */
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
- reason: `agent unhealthy: ${agent.name} ${nonCap.map((i) => i.message).join("; ")}`,
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
- const check = healthChecker ?? ((agent) => defaultAgentHealth(agent, ctx.deckOnline));
76
- for (const role of ["developer", "reviewer"]) {
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
- for (const role of ["developer", "reviewer"]) {
111
- const agentId = role === "developer" ? issue.developerAgentId : issue.reviewerAgentId;
112
- if (!agentId)
113
- continue;
114
- const agent = getAgent(agentId);
115
- if (!agent)
116
- continue;
117
- const avail = runtimeAvailability(agent.runtime);
118
- if (!avail.available) {
119
- return {
120
- ok: false,
121
- reason: `runtime capped: ${agent.runtime} until ${avail.until} (${avail.reason})`,
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, /agent unhealthy/);
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, /agent unhealthy/);
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",
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.3",
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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-dealer/shared",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-dealer",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "description": "Human control plane for agent execution — queue, plan approval, audit",
5
5
  "license": "MIT",
6
6
  "type": "module",