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
|
@@ -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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
58
|
+
/** Tests inject a pure checker so admission / pre-spawn health do not hit real CLIs. */
|
|
59
59
|
export function setAdmissionHealthCheckerForTests(checker) {
|
|
60
60
|
healthChecker = checker;
|
|
61
61
|
}
|
|
62
|
-
async function defaultAgentHealth(agent, deckOnline) {
|
|
62
|
+
async function defaultAgentHealth(agent, role, deckOnline) {
|
|
63
|
+
// Unit/CI tests must not call real CLIs / Agent Deck — runners usually have no deck MCP.
|
|
64
|
+
// Production leaves this unset. NOT-133 clears it so the real auth classifier still runs.
|
|
65
|
+
if (process.env.AGENT_DEALER_SKIP_AGENT_HEALTH === "1") {
|
|
66
|
+
return { ok: true };
|
|
67
|
+
}
|
|
63
68
|
const health = await healthForAgent(agent, deckOnline);
|
|
64
69
|
// usage_capped is owned by runtimeAvailable — keep agentsHealthy for CLI/workspace/deck.
|
|
65
70
|
const nonCap = health.issues.filter((i) => i.code !== "usage_capped");
|
|
66
71
|
if (nonCap.length > 0) {
|
|
67
72
|
return {
|
|
68
73
|
ok: false,
|
|
69
|
-
|
|
74
|
+
// NOT-156: name the role so a parked review is not read as a blocked developer admit.
|
|
75
|
+
reason: `${role} unhealthy: ${agent.name} — ${nonCap.map((i) => i.message).join("; ")}`,
|
|
70
76
|
};
|
|
71
77
|
}
|
|
72
78
|
return { ok: true };
|
|
73
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* Fail-closed health for one role (NOT-156). Admission checks the role about to start
|
|
82
|
+
* (developer for `startWorkflowCore`); the worker loop re-checks the leased item's role
|
|
83
|
+
* before spawn so a later reviewer round parks without burning infra when only the
|
|
84
|
+
* reviewer is unhealthy.
|
|
85
|
+
*/
|
|
86
|
+
export async function checkRoleAgentHealthy(issue, role, ctx) {
|
|
87
|
+
const agentId = role === "developer" ? issue.developerAgentId : issue.reviewerAgentId;
|
|
88
|
+
if (!agentId)
|
|
89
|
+
return { ok: false, reason: `missing ${role} agent` };
|
|
90
|
+
const agent = getAgent(agentId);
|
|
91
|
+
if (!agent)
|
|
92
|
+
return { ok: false, reason: `${role} agent not found` };
|
|
93
|
+
const check = healthChecker ?? ((a, r) => defaultAgentHealth(a, r, ctx.deckOnline));
|
|
94
|
+
return check(agent, role);
|
|
95
|
+
}
|
|
74
96
|
async function checkAgentsHealthy(issue, ctx) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const agentId = role === "developer" ? issue.developerAgentId : issue.reviewerAgentId;
|
|
78
|
-
if (!agentId)
|
|
79
|
-
return { ok: false, reason: `missing ${role} agent` };
|
|
80
|
-
const agent = getAgent(agentId);
|
|
81
|
-
if (!agent)
|
|
82
|
-
return { ok: false, reason: `${role} agent not found` };
|
|
83
|
-
const result = await check(agent);
|
|
84
|
-
if (!result.ok)
|
|
85
|
-
return result;
|
|
86
|
-
}
|
|
87
|
-
return { ok: true };
|
|
97
|
+
// Admit always starts a developer round — do not require reviewer health yet (NOT-156).
|
|
98
|
+
return checkRoleAgentHealthy(issue, "developer", ctx);
|
|
88
99
|
}
|
|
89
100
|
/**
|
|
90
101
|
* Status must be startable (`ready`, or `needs_human` with no active workflow) and
|
|
@@ -107,20 +118,20 @@ function issueReadinessRule(issue) {
|
|
|
107
118
|
return { ok: true };
|
|
108
119
|
}
|
|
109
120
|
function runtimeAvailableRule(issue) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
123
|
-
}
|
|
121
|
+
// NOT-156: only the developer runtime gates admit; reviewer caps are enforced at lease
|
|
122
|
+
// time in the worker loop (same place as role health for the active work item).
|
|
123
|
+
const agentId = issue.developerAgentId;
|
|
124
|
+
if (!agentId)
|
|
125
|
+
return { ok: true };
|
|
126
|
+
const agent = getAgent(agentId);
|
|
127
|
+
if (!agent)
|
|
128
|
+
return { ok: true };
|
|
129
|
+
const avail = runtimeAvailability(agent.runtime);
|
|
130
|
+
if (!avail.available) {
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
reason: `runtime capped: ${agent.runtime} until ${avail.until} (${avail.reason})`,
|
|
134
|
+
};
|
|
124
135
|
}
|
|
125
136
|
return { ok: true };
|
|
126
137
|
}
|
|
@@ -310,7 +310,10 @@ test("NOT-133: a logged-out Cursor runtime is not admitted; it waits with an aut
|
|
|
310
310
|
fs.writeFileSync(stub, `#!/bin/sh\ncat ${JSON.stringify(fixture)}\nexit 0\n`);
|
|
311
311
|
fs.chmodSync(stub, 0o755);
|
|
312
312
|
const prev = process.env.CURSOR_CLI;
|
|
313
|
+
const prevSkipHealth = process.env.AGENT_DEALER_SKIP_AGENT_HEALTH;
|
|
313
314
|
process.env.CURSOR_CLI = stub;
|
|
315
|
+
// Real classifier path — must not short-circuit via the unit-test skip.
|
|
316
|
+
delete process.env.AGENT_DEALER_SKIP_AGENT_HEALTH;
|
|
314
317
|
setAdmissionHealthCheckerForTests(null);
|
|
315
318
|
clearAgentHealthCaches();
|
|
316
319
|
try {
|
|
@@ -323,7 +326,7 @@ test("NOT-133: a logged-out Cursor runtime is not admitted; it waits with an aut
|
|
|
323
326
|
assert.equal(getActiveWorkflowInstance(issue.id), null);
|
|
324
327
|
const entry = getQueuedEntryForIssue(issue.id);
|
|
325
328
|
assert.equal(entry?.state, "queued");
|
|
326
|
-
assert.match(entry.waitReason, /
|
|
329
|
+
assert.match(entry.waitReason, /developer unhealthy/);
|
|
327
330
|
assert.match(entry.waitReason, /not authenticated/i);
|
|
328
331
|
assert.match(entry.waitReason, /cursor-agent login/);
|
|
329
332
|
}
|
|
@@ -332,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
|
}
|