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.
Files changed (36) hide show
  1. package/bundle/server/dist/adapters/agent-deck.js +68 -13
  2. package/bundle/server/dist/adapters/agent-deck.test.js +91 -1
  3. package/bundle/server/dist/adapters/agent-health.js +175 -51
  4. package/bundle/server/dist/adapters/agent-health.test.js +338 -210
  5. package/bundle/server/dist/adapters/linear-graphql.js +200 -6
  6. package/bundle/server/dist/adapters/linear-graphql.test.js +91 -1
  7. package/bundle/server/dist/coordinator/admission.js +41 -30
  8. package/bundle/server/dist/coordinator/admission.test.js +92 -1
  9. package/bundle/server/dist/coordinator/branch-tip-status.js +120 -0
  10. package/bundle/server/dist/coordinator/branch-tip-status.test.js +189 -0
  11. package/bundle/server/dist/coordinator/commands.js +6 -2
  12. package/bundle/server/dist/coordinator/developer-effect.js +76 -12
  13. package/bundle/server/dist/coordinator/developer-effect.test.js +20 -0
  14. package/bundle/server/dist/coordinator/projection.test.js +26 -0
  15. package/bundle/server/dist/coordinator/prompts.js +4 -1
  16. package/bundle/server/dist/coordinator/prompts.test.js +12 -0
  17. package/bundle/server/dist/coordinator/routing.js +43 -2
  18. package/bundle/server/dist/coordinator/routing.test.js +51 -0
  19. package/bundle/server/dist/coordinator/usage-cap-defer.js +37 -0
  20. package/bundle/server/dist/coordinator/worker-loop.js +20 -2
  21. package/bundle/server/dist/coordinator/worker-loop.test.js +55 -2
  22. package/bundle/server/dist/coordinator/workflows/dev-reviewer-v1.js +8 -0
  23. package/bundle/server/dist/coordinator/workflows/registry.js +14 -0
  24. package/bundle/server/dist/coordinator/workflows/registry.test.js +28 -0
  25. package/bundle/server/dist/coordinator/workflows/types.js +1 -0
  26. package/bundle/server/dist/index.js +2 -0
  27. package/bundle/server/dist/routes/index.js +3 -0
  28. package/bundle/server/dist/routes/issues.js +10 -1
  29. package/bundle/server/dist/routes/issues.test.js +42 -0
  30. package/bundle/server/package.json +2 -2
  31. package/bundle/server/static-ui/assets/{index-hXICi1rX.css → index-CYZRXBZj.css} +1 -1
  32. package/bundle/server/static-ui/assets/index-DXTVo_vz.js +60 -0
  33. package/bundle/server/static-ui/index.html +2 -2
  34. package/bundle/shared/package.json +1 -1
  35. package/package.json +1 -1
  36. package/bundle/server/static-ui/assets/index-0kT1vk6L.js +0 -60
@@ -2,10 +2,18 @@
2
2
  //
3
3
  // Shared Linear GraphQL POST helper (NOT-152): log HTTP failures with rate-limit
4
4
  // headers, and surface a structured error so NOT-104 can back off until reset.
5
+ //
6
+ // NOT-159: always-on per-operation success/error counters + last rate-limit headers,
7
+ // so a burn to requests-remaining=0 is diagnosable without enabling per-call trace.
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
5
11
  export const LINEAR_API = "https://api.linear.app/graphql";
6
12
  const BODY_SNIPPET_MAX = 300;
7
13
  /** Bounded default when Linear returns 429 without a usable reset / Retry-After. */
8
14
  export const DEFAULT_RATE_LIMIT_BACKOFF_MS = 60_000;
15
+ /** How often to emit a `[linear-usage]` summary when traffic > 0 since the last line. */
16
+ export const LINEAR_USAGE_SUMMARY_INTERVAL_MS = 60_000;
9
17
  export class LinearHttpError extends Error {
10
18
  status;
11
19
  operation;
@@ -102,6 +110,179 @@ function isGraphqlRateLimited(errors) {
102
110
  return ext?.code === "RATELIMITED";
103
111
  });
104
112
  }
113
+ let usageSinceMs = Date.now();
114
+ const usageByOp = new Map();
115
+ let lastRateLimit = null;
116
+ let lastOperation = null;
117
+ let lastAtMs = null;
118
+ let usageSummaryTimer = null;
119
+ let loggedTotalAtLastSummary = 0;
120
+ /** Tests redirect the durable log; null = default under AGENT_DEALER_HOME/logs. */
121
+ let usageLogPathOverride = null;
122
+ /** `$AGENT_DEALER_HOME/logs/linear-usage.jsonl` — one line per call + minute summaries. */
123
+ export function getLinearUsageLogPath() {
124
+ if (usageLogPathOverride)
125
+ return usageLogPathOverride;
126
+ const home = process.env.AGENT_DEALER_HOME ?? path.join(os.homedir(), ".agent-dealer");
127
+ return path.join(home, "logs", "linear-usage.jsonl");
128
+ }
129
+ export function setLinearUsageLogPathForTests(p) {
130
+ usageLogPathOverride = p;
131
+ }
132
+ function appendUsageLog(record) {
133
+ try {
134
+ const logPath = getLinearUsageLogPath();
135
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
136
+ fs.appendFileSync(logPath, `${JSON.stringify(record)}\n`);
137
+ }
138
+ catch (err) {
139
+ // Observability must never break Linear calls.
140
+ console.error("[linear-usage] failed to append log", err);
141
+ }
142
+ }
143
+ function bumpUsage(operation, kind, rateLimit) {
144
+ let bucket = usageByOp.get(operation);
145
+ if (!bucket) {
146
+ bucket = { ok: 0, error: 0 };
147
+ usageByOp.set(operation, bucket);
148
+ }
149
+ bucket[kind] += 1;
150
+ lastOperation = operation;
151
+ lastAtMs = Date.now();
152
+ if (rateLimit)
153
+ lastRateLimit = { ...rateLimit };
154
+ appendUsageLog({
155
+ ts: new Date(lastAtMs).toISOString(),
156
+ kind: "call",
157
+ op: operation,
158
+ ok: kind === "ok",
159
+ pid: process.pid,
160
+ requestsRemaining: rateLimit?.requestsRemaining ?? null,
161
+ requestsReset: rateLimit?.requestsReset ?? null,
162
+ requestsLimit: rateLimit?.requestsLimit ?? null,
163
+ });
164
+ ensureUsageSummaryTimer();
165
+ }
166
+ function usageTotals() {
167
+ let ok = 0;
168
+ let error = 0;
169
+ for (const b of usageByOp.values()) {
170
+ ok += b.ok;
171
+ error += b.error;
172
+ }
173
+ return { ok, error };
174
+ }
175
+ /** Process-lifetime counters for every GraphQL op that went through {@link linearGraphqlRequest}. */
176
+ export function getLinearUsageSnapshot() {
177
+ const byOperation = {};
178
+ for (const [op, b] of [...usageByOp.entries()].sort(([a], [b]) => a.localeCompare(b))) {
179
+ byOperation[op] = { ok: b.ok, error: b.error };
180
+ }
181
+ const totals = usageTotals();
182
+ return {
183
+ since: new Date(usageSinceMs).toISOString(),
184
+ totalOk: totals.ok,
185
+ totalError: totals.error,
186
+ byOperation,
187
+ lastRateLimit: lastRateLimit ? { ...lastRateLimit } : null,
188
+ lastOperation,
189
+ lastAt: lastAtMs != null ? new Date(lastAtMs).toISOString() : null,
190
+ logPath: getLinearUsageLogPath(),
191
+ };
192
+ }
193
+ export function resetLinearUsageForTests() {
194
+ usageByOp.clear();
195
+ lastRateLimit = null;
196
+ lastOperation = null;
197
+ lastAtMs = null;
198
+ usageSinceMs = Date.now();
199
+ loggedTotalAtLastSummary = 0;
200
+ usageLogPathOverride = null;
201
+ stopLinearUsageSummary();
202
+ }
203
+ function formatUsageSummaryLine() {
204
+ const snap = getLinearUsageSnapshot();
205
+ const parts = Object.entries(snap.byOperation).map(([op, c]) => `${op}:${c.ok}/${c.error}`);
206
+ const rl = snap.lastRateLimit;
207
+ const bits = [
208
+ "[linear-usage]",
209
+ `ok=${snap.totalOk}`,
210
+ `error=${snap.totalError}`,
211
+ parts.length ? `ops=${parts.join(",")}` : null,
212
+ rl?.requestsRemaining != null ? `requests-remaining=${rl.requestsRemaining}` : null,
213
+ rl?.requestsLimit != null ? `requests-limit=${rl.requestsLimit}` : null,
214
+ rl?.requestsReset != null ? `requests-reset=${rl.requestsReset}` : null,
215
+ snap.lastOperation ? `last=${snap.lastOperation}` : null,
216
+ ].filter(Boolean);
217
+ return bits.join(" ");
218
+ }
219
+ function maybeLogUsageSummary() {
220
+ const totals = usageTotals();
221
+ const total = totals.ok + totals.error;
222
+ if (total <= loggedTotalAtLastSummary)
223
+ return;
224
+ loggedTotalAtLastSummary = total;
225
+ console.error(formatUsageSummaryLine());
226
+ const snap = getLinearUsageSnapshot();
227
+ appendUsageLog({
228
+ ts: new Date().toISOString(),
229
+ kind: "summary",
230
+ pid: process.pid,
231
+ totalOk: snap.totalOk,
232
+ totalError: snap.totalError,
233
+ byOperation: snap.byOperation,
234
+ requestsRemaining: snap.lastRateLimit?.requestsRemaining ?? null,
235
+ requestsReset: snap.lastRateLimit?.requestsReset ?? null,
236
+ });
237
+ }
238
+ function ensureUsageSummaryTimer() {
239
+ if (usageSummaryTimer)
240
+ return;
241
+ usageSummaryTimer = setInterval(maybeLogUsageSummary, LINEAR_USAGE_SUMMARY_INTERVAL_MS);
242
+ // Do not keep the process alive solely for the summary ticker.
243
+ if (typeof usageSummaryTimer === "object" && "unref" in usageSummaryTimer) {
244
+ usageSummaryTimer.unref();
245
+ }
246
+ }
247
+ /** Start the once-per-minute summary logger (idempotent). Called from server boot. */
248
+ export function startLinearUsageSummary() {
249
+ ensureUsageSummaryTimer();
250
+ appendUsageLog({
251
+ ts: new Date().toISOString(),
252
+ kind: "boot",
253
+ pid: process.pid,
254
+ since: new Date(usageSinceMs).toISOString(),
255
+ logPath: getLinearUsageLogPath(),
256
+ });
257
+ }
258
+ export function stopLinearUsageSummary() {
259
+ if (!usageSummaryTimer)
260
+ return;
261
+ clearInterval(usageSummaryTimer);
262
+ usageSummaryTimer = null;
263
+ }
264
+ /** Set `AGENT_DEALER_LINEAR_TRACE=1` to log every GraphQL call (success + failure). */
265
+ function linearTraceEnabled() {
266
+ const v = process.env.AGENT_DEALER_LINEAR_TRACE?.trim().toLowerCase();
267
+ return v === "1" || v === "true" || v === "yes";
268
+ }
269
+ let linearTraceSeq = 0;
270
+ function logLinearTrace(operation, status, rateLimit) {
271
+ if (!linearTraceEnabled())
272
+ return;
273
+ linearTraceSeq += 1;
274
+ const bits = [
275
+ `[linear-trace] #${linearTraceSeq}`,
276
+ `operation=${operation}`,
277
+ `status=${status}`,
278
+ rateLimit.requestsRemaining != null ? `requests-remaining=${rateLimit.requestsRemaining}` : null,
279
+ rateLimit.requestsLimit != null ? `requests-limit=${rateLimit.requestsLimit}` : null,
280
+ rateLimit.complexityRemaining != null
281
+ ? `complexity-remaining=${rateLimit.complexityRemaining}`
282
+ : null,
283
+ ].filter(Boolean);
284
+ console.error(bits.join(" "));
285
+ }
105
286
  /**
106
287
  * POST to Linear GraphQL. On non-OK HTTP (and GraphQL RATELIMITED), logs once and throws
107
288
  * {@link LinearHttpError}. Callers keep their own catch for user-facing 502s.
@@ -110,18 +291,27 @@ export async function linearGraphqlRequest(opts) {
110
291
  const key = process.env.LINEAR_API_KEY;
111
292
  if (!key)
112
293
  throw new Error("LINEAR_API_KEY not set");
113
- const res = await fetch(LINEAR_API, {
114
- method: "POST",
115
- headers: { "Content-Type": "application/json", Authorization: key },
116
- body: JSON.stringify({ query: opts.query, variables: opts.variables }),
117
- ...(opts.timeoutMs ? { signal: AbortSignal.timeout(opts.timeoutMs) } : {}),
118
- });
294
+ let res;
295
+ try {
296
+ res = await fetch(LINEAR_API, {
297
+ method: "POST",
298
+ headers: { "Content-Type": "application/json", Authorization: key },
299
+ body: JSON.stringify({ query: opts.query, variables: opts.variables }),
300
+ ...(opts.timeoutMs ? { signal: AbortSignal.timeout(opts.timeoutMs) } : {}),
301
+ });
302
+ }
303
+ catch (err) {
304
+ bumpUsage(opts.operation, "error");
305
+ throw err;
306
+ }
119
307
  const rateLimit = parseLinearRateLimitHeaders(res.headers instanceof Headers ? res.headers : new Headers());
308
+ logLinearTrace(opts.operation, res.status, rateLimit);
120
309
  const rawBody = typeof res.text === "function"
121
310
  ? await res.text()
122
311
  : JSON.stringify(await res.json());
123
312
  const bodySnippet = truncateBody(rawBody);
124
313
  if (!res.ok) {
314
+ bumpUsage(opts.operation, "error", rateLimit);
125
315
  const err = new LinearHttpError({
126
316
  status: res.status,
127
317
  operation: opts.operation,
@@ -137,6 +327,7 @@ export async function linearGraphqlRequest(opts) {
137
327
  json = JSON.parse(rawBody);
138
328
  }
139
329
  catch {
330
+ bumpUsage(opts.operation, "error", rateLimit);
140
331
  const err = new LinearHttpError({
141
332
  status: res.status,
142
333
  operation: opts.operation,
@@ -149,6 +340,7 @@ export async function linearGraphqlRequest(opts) {
149
340
  }
150
341
  if (json.errors?.length) {
151
342
  if (isGraphqlRateLimited(json.errors) || rateLimit.requestsRemaining === "0") {
343
+ bumpUsage(opts.operation, "error", rateLimit);
152
344
  const err = new LinearHttpError({
153
345
  status: 429,
154
346
  operation: opts.operation,
@@ -159,7 +351,9 @@ export async function linearGraphqlRequest(opts) {
159
351
  console.error(formatLinearFailureLog(err));
160
352
  throw err;
161
353
  }
354
+ bumpUsage(opts.operation, "error", rateLimit);
162
355
  throw new Error(JSON.stringify(json.errors));
163
356
  }
357
+ bumpUsage(opts.operation, "ok", rateLimit);
164
358
  return json.data;
165
359
  }
@@ -4,7 +4,7 @@
4
4
  // and expose a structured error so NOT-104 can back off until the reset window.
5
5
  import { test, mock } from "node:test";
6
6
  import assert from "node:assert/strict";
7
- const { LinearHttpError, computeRetryAfterMs, linearGraphqlRequest, parseLinearRateLimitHeaders, } = await import("./linear-graphql.js");
7
+ const { LinearHttpError, computeRetryAfterMs, getLinearUsageLogPath, getLinearUsageSnapshot, linearGraphqlRequest, parseLinearRateLimitHeaders, resetLinearUsageForTests, setLinearUsageLogPathForTests, } = await import("./linear-graphql.js");
8
8
  test("parseLinearRateLimitHeaders reads requests + complexity headers", () => {
9
9
  const headers = new Headers({
10
10
  "X-RateLimit-Requests-Limit": "2500",
@@ -45,6 +45,7 @@ test("computeRetryAfterMs falls back to Retry-After seconds when reset missing",
45
45
  });
46
46
  test("linearGraphqlRequest logs HTTP status and rate-limit headers on non-OK", async () => {
47
47
  process.env.LINEAR_API_KEY = "lin_test";
48
+ resetLinearUsageForTests();
48
49
  const errors = [];
49
50
  const errorMock = mock.method(console, "error", (...args) => {
50
51
  errors.push(args);
@@ -78,10 +79,99 @@ test("linearGraphqlRequest logs HTTP status and rate-limit headers on non-OK", a
78
79
  assert.match(joined, /429/);
79
80
  assert.match(joined, /requests-remaining[=:]?\s*0/i);
80
81
  assert.match(joined, /requests-reset[=:]?\s*1760000045000/i);
82
+ const usage = getLinearUsageSnapshot();
83
+ assert.equal(usage.byOperation.listLinearCandidates?.error, 1);
84
+ assert.equal(usage.byOperation.listLinearCandidates?.ok, 0);
85
+ assert.equal(usage.lastRateLimit?.requestsRemaining, "0");
81
86
  }
82
87
  finally {
83
88
  errorMock.mock.restore();
84
89
  fetchMock.mock.restore();
90
+ resetLinearUsageForTests();
91
+ }
92
+ });
93
+ test("NOT-159: successful GraphQL increments ok counter and last requests-remaining", async () => {
94
+ process.env.LINEAR_API_KEY = "lin_test";
95
+ resetLinearUsageForTests();
96
+ const fs = await import("node:fs");
97
+ const os = await import("node:os");
98
+ const path = await import("node:path");
99
+ const logPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "lin-usage-")), "linear-usage.jsonl");
100
+ setLinearUsageLogPathForTests(logPath);
101
+ const fetchMock = mock.method(globalThis, "fetch", async () => {
102
+ return new Response(JSON.stringify({ data: { viewer: { id: "u1" } } }), {
103
+ status: 200,
104
+ headers: {
105
+ "Content-Type": "application/json",
106
+ "X-RateLimit-Requests-Remaining": "2497",
107
+ "X-RateLimit-Requests-Limit": "2500",
108
+ "X-RateLimit-Requests-Reset": "1760000100000",
109
+ },
110
+ });
111
+ });
112
+ try {
113
+ const data = await linearGraphqlRequest({
114
+ operation: "getLinearViewer",
115
+ query: "query { viewer { id } }",
116
+ });
117
+ assert.deepEqual(data, { viewer: { id: "u1" } });
118
+ const usage = getLinearUsageSnapshot();
119
+ assert.equal(usage.totalOk, 1);
120
+ assert.equal(usage.totalError, 0);
121
+ assert.equal(usage.byOperation.getLinearViewer?.ok, 1);
122
+ assert.equal(usage.lastOperation, "getLinearViewer");
123
+ assert.equal(usage.lastRateLimit?.requestsRemaining, "2497");
124
+ assert.equal(usage.lastRateLimit?.requestsLimit, "2500");
125
+ assert.equal(usage.logPath, logPath);
126
+ assert.equal(getLinearUsageLogPath(), logPath);
127
+ const lines = fs.readFileSync(logPath, "utf8").trim().split("\n");
128
+ assert.equal(lines.length, 1);
129
+ const row = JSON.parse(lines[0]);
130
+ assert.equal(row.kind, "call");
131
+ assert.equal(row.op, "getLinearViewer");
132
+ assert.equal(row.ok, true);
133
+ assert.equal(row.requestsRemaining, "2497");
134
+ }
135
+ finally {
136
+ fetchMock.mock.restore();
137
+ resetLinearUsageForTests();
138
+ }
139
+ });
140
+ test("NOT-159: counters accumulate per operation across success and failure", async () => {
141
+ process.env.LINEAR_API_KEY = "lin_test";
142
+ resetLinearUsageForTests();
143
+ let n = 0;
144
+ const fetchMock = mock.method(globalThis, "fetch", async () => {
145
+ n += 1;
146
+ if (n === 2) {
147
+ return new Response("nope", {
148
+ status: 502,
149
+ headers: { "X-RateLimit-Requests-Remaining": "9" },
150
+ });
151
+ }
152
+ return new Response(JSON.stringify({ data: { ok: true } }), {
153
+ status: 200,
154
+ headers: {
155
+ "Content-Type": "application/json",
156
+ "X-RateLimit-Requests-Remaining": String(11 - n),
157
+ },
158
+ });
159
+ });
160
+ const errorMock = mock.method(console, "error", () => { });
161
+ try {
162
+ await linearGraphqlRequest({ operation: "fetchLinearBlockers", query: "query { a }" });
163
+ await assert.rejects(() => linearGraphqlRequest({ operation: "fetchLinearBlockers", query: "query { a }" }));
164
+ await linearGraphqlRequest({ operation: "listLinearCandidates", query: "query { b }" });
165
+ const usage = getLinearUsageSnapshot();
166
+ assert.deepEqual(usage.byOperation.fetchLinearBlockers, { ok: 1, error: 1 });
167
+ assert.deepEqual(usage.byOperation.listLinearCandidates, { ok: 1, error: 0 });
168
+ assert.equal(usage.totalOk, 2);
169
+ assert.equal(usage.totalError, 1);
170
+ }
171
+ finally {
172
+ errorMock.mock.restore();
173
+ fetchMock.mock.restore();
174
+ resetLinearUsageForTests();
85
175
  }
86
176
  });
87
177
  test("listLinearCandidates leaves a [linear] log line on HTTP 502 (intake path)", async () => {
@@ -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,6 +335,94 @@ 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;
342
+ clearAgentHealthCaches();
343
+ setAdmissionHealthCheckerForTests(async () => ({ ok: true }));
344
+ }
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
+ });
388
+ // NOT-157: soft probe failure wait_reason must name the probe, not "not authenticated".
389
+ test("NOT-157: soft Cursor probe timeout waits with probe-timeout reason, not logged-out copy", async () => {
390
+ const { clearAgentHealthCaches, setCursorProbeTimingForTests, } = await import("../adapters/agent-health.js");
391
+ // Real hang stub (same style as NOT-133) so this file does not share runCommand inject
392
+ // state with agent-health.test.ts when the suite runs files in parallel.
393
+ const stubDir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-cursor-timeout-"));
394
+ const stub = path.join(stubDir, "cursor-agent");
395
+ fs.writeFileSync(stub, "#!/bin/sh\nsleep 30\n");
396
+ fs.chmodSync(stub, 0o755);
397
+ const prev = process.env.CURSOR_CLI;
398
+ const prevSkipHealth = process.env.AGENT_DEALER_SKIP_AGENT_HEALTH;
399
+ process.env.CURSOR_CLI = stub;
400
+ delete process.env.AGENT_DEALER_SKIP_AGENT_HEALTH;
401
+ setCursorProbeTimingForTests({ timeoutMs: 150, retryBackoffsMs: [20] });
402
+ setAdmissionHealthCheckerForTests(null);
403
+ clearAgentHealthCaches();
404
+ try {
405
+ const issue = readyIssue("cursor-probe-timeout", {
406
+ runtimes: { dev: "cursor_local", rev: "cursor_local" },
407
+ });
408
+ enqueueIssue(issue.id);
409
+ assert.equal(await admitNext(), null);
410
+ const entry = getQueuedEntryForIssue(issue.id);
411
+ assert.equal(entry?.state, "queued");
412
+ assert.match(entry.waitReason, /developer unhealthy/);
413
+ assert.match(entry.waitReason, /probe timed out/i);
414
+ assert.doesNotMatch(entry.waitReason, /not authenticated/i);
415
+ }
416
+ finally {
417
+ if (prev === undefined)
418
+ delete process.env.CURSOR_CLI;
419
+ else
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;
425
+ setCursorProbeTimingForTests(null);
335
426
  clearAgentHealthCaches();
336
427
  setAdmissionHealthCheckerForTests(async () => ({ ok: true }));
337
428
  }