agent-dealer 1.0.1 → 1.0.2

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 (27) hide show
  1. package/bundle/server/dist/adapters/linear-graphql.js +165 -0
  2. package/bundle/server/dist/adapters/linear-graphql.test.js +129 -0
  3. package/bundle/server/dist/adapters/linear-inbox.js +12 -21
  4. package/bundle/server/dist/adapters/linear-sync.js +6 -19
  5. package/bundle/server/dist/coordinator/dependencies.js +5 -1
  6. package/bundle/server/dist/coordinator/dependency-readiness.integration.test.js +33 -1
  7. package/bundle/server/dist/db/index.js +6 -0
  8. package/bundle/server/dist/direct-start-interrupt-probe.js +58 -0
  9. package/bundle/server/dist/direct-start-liveness.integration.test.js +114 -24
  10. package/bundle/server/dist/direct-start-temp-home-cleanup.js +124 -0
  11. package/bundle/server/dist/direct-start-temp-home-cleanup.test.js +92 -0
  12. package/bundle/server/dist/repository/queue-entries.js +49 -11
  13. package/bundle/server/dist/routes/queue-reorder.integration.test.js +144 -0
  14. package/bundle/server/dist/routes/queue.js +21 -2
  15. package/bundle/server/package.json +2 -2
  16. package/bundle/server/static-ui/assets/index-0kT1vk6L.js +60 -0
  17. package/bundle/server/static-ui/assets/{index-BxTx4b1d.css → index-hXICi1rX.css} +1 -1
  18. package/bundle/server/static-ui/index.html +2 -2
  19. package/bundle/shared/dist/queue-entries.d.ts +47 -0
  20. package/bundle/shared/dist/queue-entries.js +14 -0
  21. package/bundle/shared/package.json +1 -1
  22. package/dist/index.js +1 -0
  23. package/dist/queue.d.ts +5 -0
  24. package/dist/queue.js +50 -0
  25. package/dist/queue.test.js +39 -0
  26. package/package.json +1 -1
  27. package/bundle/server/static-ui/assets/index-BTtPGYpY.js +0 -60
@@ -0,0 +1,165 @@
1
+ // packages/server/src/adapters/linear-graphql.ts
2
+ //
3
+ // Shared Linear GraphQL POST helper (NOT-152): log HTTP failures with rate-limit
4
+ // headers, and surface a structured error so NOT-104 can back off until reset.
5
+ export const LINEAR_API = "https://api.linear.app/graphql";
6
+ const BODY_SNIPPET_MAX = 300;
7
+ /** Bounded default when Linear returns 429 without a usable reset / Retry-After. */
8
+ export const DEFAULT_RATE_LIMIT_BACKOFF_MS = 60_000;
9
+ export class LinearHttpError extends Error {
10
+ status;
11
+ operation;
12
+ rateLimit;
13
+ bodySnippet;
14
+ /** ms until the rate-limit window reopens; null when not a rate-limit / unknown. */
15
+ retryAfterMs;
16
+ constructor(init) {
17
+ const remaining = init.rateLimit.requestsRemaining;
18
+ const reset = init.rateLimit.requestsReset;
19
+ const parts = [
20
+ `Linear HTTP ${init.status}`,
21
+ `operation=${init.operation}`,
22
+ remaining != null ? `requests-remaining=${remaining}` : null,
23
+ reset != null ? `requests-reset=${reset}` : null,
24
+ ].filter(Boolean);
25
+ super(parts.join(" "));
26
+ this.name = "LinearHttpError";
27
+ this.status = init.status;
28
+ this.operation = init.operation;
29
+ this.rateLimit = init.rateLimit;
30
+ this.bodySnippet = init.bodySnippet;
31
+ this.retryAfterMs = init.retryAfterMs;
32
+ }
33
+ }
34
+ export function parseLinearRateLimitHeaders(headers) {
35
+ const out = {};
36
+ const set = (key, name) => {
37
+ const v = headers.get(name);
38
+ if (v != null && v !== "")
39
+ out[key] = v;
40
+ };
41
+ set("requestsLimit", "x-ratelimit-requests-limit");
42
+ set("requestsRemaining", "x-ratelimit-requests-remaining");
43
+ set("requestsReset", "x-ratelimit-requests-reset");
44
+ set("complexityLimit", "x-ratelimit-complexity-limit");
45
+ set("complexityRemaining", "x-ratelimit-complexity-remaining");
46
+ set("complexityReset", "x-ratelimit-complexity-reset");
47
+ set("complexity", "x-complexity");
48
+ set("retryAfter", "retry-after");
49
+ return out;
50
+ }
51
+ /**
52
+ * How long to wait before retrying a rate-limited call.
53
+ * Linear's `X-RateLimit-Requests-Reset` is UTC epoch **milliseconds**.
54
+ * Prefer that reset header when present (NOT-152); Retry-After / default are fallbacks.
55
+ */
56
+ export function computeRetryAfterMs(status, rateLimit, nowMs = Date.now()) {
57
+ if (status !== 429 && rateLimit.requestsRemaining !== "0")
58
+ return null;
59
+ if (rateLimit.requestsReset) {
60
+ const resetMs = Number(rateLimit.requestsReset);
61
+ if (Number.isFinite(resetMs) && resetMs > 0) {
62
+ // Accept seconds-epoch (10 digits) in case a proxy rewrites the header.
63
+ const absolute = resetMs < 1e12 ? resetMs * 1000 : resetMs;
64
+ return Math.max(0, absolute - nowMs);
65
+ }
66
+ }
67
+ if (rateLimit.retryAfter) {
68
+ const seconds = Number(rateLimit.retryAfter);
69
+ if (Number.isFinite(seconds) && seconds >= 0)
70
+ return Math.ceil(seconds * 1000);
71
+ }
72
+ if (status === 429)
73
+ return DEFAULT_RATE_LIMIT_BACKOFF_MS;
74
+ return null;
75
+ }
76
+ export function formatLinearFailureLog(err) {
77
+ const rl = err.rateLimit;
78
+ const bits = [
79
+ `[linear] ${err.operation} failed`,
80
+ `status=${err.status}`,
81
+ rl.requestsRemaining != null ? `requests-remaining=${rl.requestsRemaining}` : null,
82
+ rl.requestsReset != null ? `requests-reset=${rl.requestsReset}` : null,
83
+ rl.requestsLimit != null ? `requests-limit=${rl.requestsLimit}` : null,
84
+ rl.complexityRemaining != null ? `complexity-remaining=${rl.complexityRemaining}` : null,
85
+ rl.complexityReset != null ? `complexity-reset=${rl.complexityReset}` : null,
86
+ rl.complexity != null ? `complexity=${rl.complexity}` : null,
87
+ err.bodySnippet ? `body=${err.bodySnippet}` : null,
88
+ ].filter(Boolean);
89
+ return bits.join(" ");
90
+ }
91
+ function truncateBody(text) {
92
+ const oneLine = text.replace(/\s+/g, " ").trim();
93
+ if (oneLine.length <= BODY_SNIPPET_MAX)
94
+ return oneLine;
95
+ return `${oneLine.slice(0, BODY_SNIPPET_MAX)}…`;
96
+ }
97
+ function isGraphqlRateLimited(errors) {
98
+ return errors.some((e) => {
99
+ if (!e || typeof e !== "object")
100
+ return false;
101
+ const ext = e.extensions;
102
+ return ext?.code === "RATELIMITED";
103
+ });
104
+ }
105
+ /**
106
+ * POST to Linear GraphQL. On non-OK HTTP (and GraphQL RATELIMITED), logs once and throws
107
+ * {@link LinearHttpError}. Callers keep their own catch for user-facing 502s.
108
+ */
109
+ export async function linearGraphqlRequest(opts) {
110
+ const key = process.env.LINEAR_API_KEY;
111
+ if (!key)
112
+ 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
+ });
119
+ const rateLimit = parseLinearRateLimitHeaders(res.headers instanceof Headers ? res.headers : new Headers());
120
+ const rawBody = typeof res.text === "function"
121
+ ? await res.text()
122
+ : JSON.stringify(await res.json());
123
+ const bodySnippet = truncateBody(rawBody);
124
+ if (!res.ok) {
125
+ const err = new LinearHttpError({
126
+ status: res.status,
127
+ operation: opts.operation,
128
+ rateLimit,
129
+ bodySnippet,
130
+ retryAfterMs: computeRetryAfterMs(res.status, rateLimit),
131
+ });
132
+ console.error(formatLinearFailureLog(err));
133
+ throw err;
134
+ }
135
+ let json;
136
+ try {
137
+ json = JSON.parse(rawBody);
138
+ }
139
+ catch {
140
+ const err = new LinearHttpError({
141
+ status: res.status,
142
+ operation: opts.operation,
143
+ rateLimit,
144
+ bodySnippet,
145
+ retryAfterMs: null,
146
+ });
147
+ console.error(formatLinearFailureLog(err));
148
+ throw new Error(`Linear GraphQL returned non-JSON for ${opts.operation}`);
149
+ }
150
+ if (json.errors?.length) {
151
+ if (isGraphqlRateLimited(json.errors) || rateLimit.requestsRemaining === "0") {
152
+ const err = new LinearHttpError({
153
+ status: 429,
154
+ operation: opts.operation,
155
+ rateLimit,
156
+ bodySnippet,
157
+ retryAfterMs: computeRetryAfterMs(429, rateLimit),
158
+ });
159
+ console.error(formatLinearFailureLog(err));
160
+ throw err;
161
+ }
162
+ throw new Error(JSON.stringify(json.errors));
163
+ }
164
+ return json.data;
165
+ }
@@ -0,0 +1,129 @@
1
+ // packages/server/src/adapters/linear-graphql.test.ts
2
+ //
3
+ // NOT-152: failed Linear GraphQL calls must log HTTP status + rate-limit headers,
4
+ // and expose a structured error so NOT-104 can back off until the reset window.
5
+ import { test, mock } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ const { LinearHttpError, computeRetryAfterMs, linearGraphqlRequest, parseLinearRateLimitHeaders, } = await import("./linear-graphql.js");
8
+ test("parseLinearRateLimitHeaders reads requests + complexity headers", () => {
9
+ const headers = new Headers({
10
+ "X-RateLimit-Requests-Limit": "2500",
11
+ "X-RateLimit-Requests-Remaining": "0",
12
+ "X-RateLimit-Requests-Reset": "1760000000000",
13
+ "X-RateLimit-Complexity-Limit": "1000000",
14
+ "X-RateLimit-Complexity-Remaining": "12",
15
+ "X-RateLimit-Complexity-Reset": "1760000000000",
16
+ "X-Complexity": "42",
17
+ });
18
+ assert.deepEqual(parseLinearRateLimitHeaders(headers), {
19
+ requestsLimit: "2500",
20
+ requestsRemaining: "0",
21
+ requestsReset: "1760000000000",
22
+ complexityLimit: "1000000",
23
+ complexityRemaining: "12",
24
+ complexityReset: "1760000000000",
25
+ complexity: "42",
26
+ });
27
+ });
28
+ test("computeRetryAfterMs uses Linear reset epoch-ms on 429", () => {
29
+ const now = 1_760_000_000_000;
30
+ const ms = computeRetryAfterMs(429, { requestsRemaining: "0", requestsReset: String(now + 45_000) }, now);
31
+ assert.equal(ms, 45_000);
32
+ });
33
+ test("computeRetryAfterMs prefers requests-reset over Retry-After when both are set", () => {
34
+ const now = 1_760_000_000_000;
35
+ const ms = computeRetryAfterMs(429, {
36
+ requestsRemaining: "0",
37
+ requestsReset: String(now + 45_000),
38
+ retryAfter: "5",
39
+ }, now);
40
+ assert.equal(ms, 45_000);
41
+ });
42
+ test("computeRetryAfterMs falls back to Retry-After seconds when reset missing", () => {
43
+ const ms = computeRetryAfterMs(429, { retryAfter: "30" }, 1_000);
44
+ assert.equal(ms, 30_000);
45
+ });
46
+ test("linearGraphqlRequest logs HTTP status and rate-limit headers on non-OK", async () => {
47
+ process.env.LINEAR_API_KEY = "lin_test";
48
+ const errors = [];
49
+ const errorMock = mock.method(console, "error", (...args) => {
50
+ errors.push(args);
51
+ });
52
+ const fetchMock = mock.method(globalThis, "fetch", async () => {
53
+ return new Response("rate limited", {
54
+ status: 429,
55
+ headers: {
56
+ "X-RateLimit-Requests-Remaining": "0",
57
+ "X-RateLimit-Requests-Reset": "1760000045000",
58
+ "X-RateLimit-Requests-Limit": "2500",
59
+ },
60
+ });
61
+ });
62
+ try {
63
+ await assert.rejects(() => linearGraphqlRequest({
64
+ operation: "listLinearCandidates",
65
+ query: "query { viewer { id } }",
66
+ }), (err) => {
67
+ assert.ok(err instanceof LinearHttpError);
68
+ assert.equal(err.status, 429);
69
+ assert.equal(err.operation, "listLinearCandidates");
70
+ assert.equal(err.rateLimit.requestsRemaining, "0");
71
+ assert.equal(err.rateLimit.requestsReset, "1760000045000");
72
+ assert.match(err.message, /429/);
73
+ return true;
74
+ });
75
+ const joined = errors.map((a) => a.map(String).join(" ")).join("\n");
76
+ assert.match(joined, /\[linear\]/);
77
+ assert.match(joined, /listLinearCandidates/);
78
+ assert.match(joined, /429/);
79
+ assert.match(joined, /requests-remaining[=:]?\s*0/i);
80
+ assert.match(joined, /requests-reset[=:]?\s*1760000045000/i);
81
+ }
82
+ finally {
83
+ errorMock.mock.restore();
84
+ fetchMock.mock.restore();
85
+ }
86
+ });
87
+ test("listLinearCandidates leaves a [linear] log line on HTTP 502 (intake path)", async () => {
88
+ // Mirrors GET /api/intake/linear: route returns String(e) to the client; dealer logs must
89
+ // still carry status + rate-limit headers from linearGraphqlRequest.
90
+ const fs = await import("node:fs");
91
+ const os = await import("node:os");
92
+ const path = await import("node:path");
93
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-not152-"));
94
+ process.env.LINEAR_API_KEY = "lin_test";
95
+ const { migrate } = await import("../db/index.js");
96
+ migrate();
97
+ const { listLinearCandidates } = await import("./linear-inbox.js");
98
+ const errors = [];
99
+ const errorMock = mock.method(console, "error", (...args) => {
100
+ errors.push(args);
101
+ });
102
+ const fetchMock = mock.method(globalThis, "fetch", async () => {
103
+ return new Response("bad gateway", {
104
+ status: 502,
105
+ headers: {
106
+ "X-RateLimit-Requests-Remaining": "12",
107
+ "X-RateLimit-Requests-Reset": "1760000099000",
108
+ },
109
+ });
110
+ });
111
+ try {
112
+ await assert.rejects(() => listLinearCandidates(), (err) => {
113
+ assert.ok(err instanceof LinearHttpError);
114
+ assert.equal(err.status, 502);
115
+ assert.equal(err.operation, "listLinearCandidates");
116
+ return true;
117
+ });
118
+ const joined = errors.map((a) => a.map(String).join(" ")).join("\n");
119
+ assert.match(joined, /\[linear\]/);
120
+ assert.match(joined, /listLinearCandidates/);
121
+ assert.match(joined, /status=502/);
122
+ assert.match(joined, /requests-remaining=12/);
123
+ assert.match(joined, /requests-reset=1760000099000/);
124
+ }
125
+ finally {
126
+ errorMock.mock.restore();
127
+ fetchMock.mock.restore();
128
+ }
129
+ });
@@ -1,26 +1,17 @@
1
1
  import { DEFAULT_LINEAR_STATE_FILTER, getLinearIntakeConfig } from "../repository/intake-settings.js";
2
+ import { linearGraphqlRequest } from "./linear-graphql.js";
2
3
  export { DEFAULT_LINEAR_STATE_FILTER };
3
- const LINEAR_API = "https://api.linear.app/graphql";
4
4
  const PAGE_SIZE = 50;
5
5
  function hasApiKey() {
6
6
  return Boolean(process.env.LINEAR_API_KEY);
7
7
  }
8
- async function linearQuery(query, variables, opts) {
9
- const key = process.env.LINEAR_API_KEY;
10
- if (!key)
11
- throw new Error("LINEAR_API_KEY not set");
12
- const res = await fetch(LINEAR_API, {
13
- method: "POST",
14
- headers: { "Content-Type": "application/json", Authorization: key },
15
- body: JSON.stringify({ query, variables }),
16
- ...(opts?.timeoutMs ? { signal: AbortSignal.timeout(opts.timeoutMs) } : {}),
8
+ async function linearQuery(operation, query, variables, opts) {
9
+ return linearGraphqlRequest({
10
+ operation,
11
+ query,
12
+ variables,
13
+ timeoutMs: opts?.timeoutMs,
17
14
  });
18
- if (!res.ok)
19
- throw new Error(`Linear HTTP ${res.status}: ${await res.text()}`);
20
- const json = (await res.json());
21
- if (json.errors?.length)
22
- throw new Error(JSON.stringify(json.errors));
23
- return json.data;
24
15
  }
25
16
  function nodeToCandidate(n) {
26
17
  return {
@@ -47,7 +38,7 @@ const ISSUE_FIELDS = `
47
38
  export async function getLinearViewer() {
48
39
  if (!hasApiKey())
49
40
  return null;
50
- const data = (await linearQuery(`query { viewer { id name email } }`));
41
+ const data = (await linearQuery("getLinearViewer", `query { viewer { id name email } }`));
51
42
  return data.viewer;
52
43
  }
53
44
  export function buildIssueFilter(settings, viewerId) {
@@ -95,7 +86,7 @@ export async function listLinearCandidates() {
95
86
  const nodes = [];
96
87
  let after;
97
88
  for (;;) {
98
- const data = (await linearQuery(`query PollIssues($filter: IssueFilter, $after: String) {
89
+ const data = (await linearQuery("listLinearCandidates", `query PollIssues($filter: IssueFilter, $after: String) {
99
90
  issues(filter: $filter, first: ${PAGE_SIZE}, after: $after) {
100
91
  nodes { ${ISSUE_FIELDS} }
101
92
  pageInfo { hasNextPage endCursor }
@@ -109,7 +100,7 @@ export async function listLinearCandidates() {
109
100
  return nodes.map(nodeToCandidate);
110
101
  }
111
102
  export async function getLinearIssue(issueId) {
112
- const data = (await linearQuery(`query Issue($id: String!) {
103
+ const data = (await linearQuery("getLinearIssue", `query Issue($id: String!) {
113
104
  issue(id: $id) { ${ISSUE_FIELDS} }
114
105
  }`, { id: issueId }));
115
106
  if (!data.issue)
@@ -172,7 +163,7 @@ export async function fetchLinearBlockers(issueIds, opts = {}) {
172
163
  let paging = [];
173
164
  let after;
174
165
  for (;;) {
175
- const data = (await linearQuery(`query BlockingRelations($ids: [ID!], $after: String) {
166
+ const data = (await linearQuery("fetchLinearBlockers", `query BlockingRelations($ids: [ID!], $after: String) {
176
167
  issues(filter: { id: { in: $ids } }, first: ${PAGE_SIZE}, after: $after, includeArchived: true) {
177
168
  nodes {
178
169
  id
@@ -263,7 +254,7 @@ async function fetchInverseRelationRound(pending, remaining) {
263
254
  variables[`ids${i}`] = [entry.id];
264
255
  variables[`after${i}`] = entry.after;
265
256
  });
266
- const data = (await linearQuery(`query BlockingRelationsPage(${varDefs.join(", ")}) {\n${selections.join("\n")}\n}`, variables, { timeoutMs: remaining() }));
257
+ const data = (await linearQuery("fetchLinearBlockersPage", `query BlockingRelationsPage(${varDefs.join(", ")}) {\n${selections.join("\n")}\n}`, variables, { timeoutMs: remaining() }));
267
258
  const out = new Map();
268
259
  pending.forEach((entry, i) => {
269
260
  const page = data[`r${i}`]?.nodes?.find((n) => n.id === entry.id)?.inverseRelations;
@@ -1,7 +1,7 @@
1
1
  import { addArtifact } from "../repository/runs.js";
2
2
  import { getLinearIntakeConfig } from "../repository/intake-settings.js";
3
3
  import { getLinearIssue } from "./linear-inbox.js";
4
- const LINEAR_API = "https://api.linear.app/graphql";
4
+ import { linearGraphqlRequest } from "./linear-graphql.js";
5
5
  const STATE_BY_EVENT = {
6
6
  // TODO(P2): configurable per team — see docs/LINEAR_INTEGRATION.md
7
7
  done: "Done",
@@ -10,27 +10,14 @@ const workflowStateCache = new Map();
10
10
  function webBaseUrl() {
11
11
  return process.env.AGENT_DEALER_WEB_URL ?? "http://localhost:2222";
12
12
  }
13
- async function linearMutate(query, variables) {
14
- const key = process.env.LINEAR_API_KEY;
15
- if (!key)
16
- throw new Error("LINEAR_API_KEY not set");
17
- const res = await fetch(LINEAR_API, {
18
- method: "POST",
19
- headers: { "Content-Type": "application/json", Authorization: key },
20
- body: JSON.stringify({ query, variables }),
21
- });
22
- if (!res.ok)
23
- throw new Error(`Linear HTTP ${res.status}: ${await res.text()}`);
24
- const json = (await res.json());
25
- if (json.errors?.length)
26
- throw new Error(JSON.stringify(json.errors));
27
- return json.data;
13
+ async function linearMutate(operation, query, variables) {
14
+ return linearGraphqlRequest({ operation, query, variables });
28
15
  }
29
16
  async function getWorkflowStates(teamId) {
30
17
  const cached = workflowStateCache.get(teamId);
31
18
  if (cached)
32
19
  return cached;
33
- const data = (await linearMutate(`query TeamStates($teamId: String!) {
20
+ const data = (await linearMutate("getWorkflowStates", `query TeamStates($teamId: String!) {
34
21
  team(id: $teamId) {
35
22
  states { nodes { id name } }
36
23
  }
@@ -43,12 +30,12 @@ async function getWorkflowStates(teamId) {
43
30
  return map;
44
31
  }
45
32
  async function commentCreate(issueId, body) {
46
- await linearMutate(`mutation Comment($issueId: String!, $body: String!) {
33
+ await linearMutate("commentCreate", `mutation Comment($issueId: String!, $body: String!) {
47
34
  commentCreate(input: { issueId: $issueId, body: $body }) { success }
48
35
  }`, { issueId, body });
49
36
  }
50
37
  async function issueUpdateState(issueId, stateId) {
51
- await linearMutate(`mutation UpdateIssue($issueId: String!, $stateId: String!) {
38
+ await linearMutate("issueUpdateState", `mutation UpdateIssue($issueId: String!, $stateId: String!) {
52
39
  issueUpdate(id: $issueId, input: { stateId: $stateId }) { success }
53
40
  }`, { issueId, stateId });
54
41
  }
@@ -16,6 +16,7 @@
16
16
  // A persisted table can replace the provider later without touching the rule.
17
17
  import { isTerminalIssueStatus } from "@agent-dealer/shared";
18
18
  import { fetchLinearBlockers } from "../adapters/linear-inbox.js";
19
+ import { LinearHttpError } from "../adapters/linear-graphql.js";
19
20
  import { listIssuesByExternalId } from "../repository/issues.js";
20
21
  const EMPTY_SNAPSHOT = new Map();
21
22
  const DEFAULT_CACHE_TTL_MS = 60_000;
@@ -124,7 +125,10 @@ export async function blockersFor(issues) {
124
125
  fetched = await pending;
125
126
  }
126
127
  catch (err) {
127
- backoffUntil = Date.now() + failureBackoffMs;
128
+ // NOT-152: a 429 / exhausted budget must wait for Linear's reset window, not only the
129
+ // short FAILURE_BACKOFF_MS — otherwise admission re-hammers once that window lapses.
130
+ const rateLimitMs = err instanceof LinearHttpError && err.retryAfterMs != null ? err.retryAfterMs : 0;
131
+ backoffUntil = Date.now() + Math.max(failureBackoffMs, rateLimitMs);
128
132
  throw err;
129
133
  }
130
134
  finally {
@@ -322,6 +322,35 @@ test("a failing Linear is asked once per backoff window, not once per tick", asy
322
322
  setLinearBlockerFetcherForTests(async (ids) => new Map(ids.map((id) => [id, []])));
323
323
  assert.equal((await admitNext())?.issueId, issue.id);
324
324
  });
325
+ test("HTTP 429 backs off until Linear's reset header, not only the short failure window", async () => {
326
+ // NOT-152: a rate-limit must outlive FAILURE_BACKOFF_MS when X-RateLimit-Requests-Reset says so.
327
+ const { LinearHttpError } = await import("../adapters/linear-graphql.js");
328
+ const issue = seedIssue({ source: "linear", externalId: "lin-a" });
329
+ let fetches = 0;
330
+ setBlockerFailureBackoffForTests(50);
331
+ setLinearBlockerFetcherForTests(async () => {
332
+ fetches++;
333
+ throw new LinearHttpError({
334
+ status: 429,
335
+ operation: "fetchLinearBlockers",
336
+ rateLimit: {
337
+ requestsRemaining: "0",
338
+ requestsReset: String(Date.now() + 60_000),
339
+ },
340
+ bodySnippet: "rate limited",
341
+ retryAfterMs: 60_000,
342
+ });
343
+ });
344
+ await assert.rejects(() => blockersFor([issue]), (err) => {
345
+ assert.ok(err instanceof LinearHttpError);
346
+ assert.equal(err.status, 429);
347
+ return true;
348
+ });
349
+ assert.equal(fetches, 1);
350
+ await new Promise((r) => setTimeout(r, 80));
351
+ await assert.rejects(() => blockersFor([issue]), /backing off/);
352
+ assert.equal(fetches, 1, "must not re-hit Linear while the rate-limit window is open");
353
+ });
325
354
  test("overlapping ticks share one fetch: the second parks instead of opening a second call", async () => {
326
355
  const issue = seedIssue({ source: "linear", externalId: "lin-a" });
327
356
  let release = () => { };
@@ -371,7 +400,10 @@ function stubLinearIssues(batch, rounds = []) {
371
400
  else {
372
401
  data = { issues: { nodes: batch, pageInfo: { hasNextPage: false, endCursor: null } } };
373
402
  }
374
- return { ok: true, json: async () => ({ data }) };
403
+ return new Response(JSON.stringify({ data }), {
404
+ status: 200,
405
+ headers: { "Content-Type": "application/json" },
406
+ });
375
407
  });
376
408
  return {
377
409
  requests,
@@ -459,6 +459,12 @@ function seedIntakeSettings(db) {
459
459
  }
460
460
  }
461
461
  function seedBuiltinAgents(db) {
462
+ // Only seed an empty agents table (fresh install). If the operator deleted the
463
+ // default Claude/Cursor/Codex rows after creating their own profiles, do not
464
+ // resurrect them on every migrate — INSERT OR IGNORE would bring the fixed IDs back.
465
+ const existing = db.prepare("SELECT COUNT(*) AS c FROM agents").get();
466
+ if (existing.c > 0)
467
+ return;
462
468
  const now = new Date().toISOString();
463
469
  const insert = db.prepare(`
464
470
  INSERT OR IGNORE INTO agents (id, name, runtime, deck_id, deck_name, playbook_id, is_builtin, created_at, updated_at)
@@ -0,0 +1,58 @@
1
+ // packages/server/src/direct-start-interrupt-probe.ts
2
+ //
3
+ // Spawned by direct-start-liveness.integration.test.ts to prove the SIGINT path
4
+ // reaps both the detached server group and the tracked temp home (NOT-140).
5
+ // Args: <home> <readyFile> <serverEntry> <port>
6
+ import { spawn } from "node:child_process";
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { DirectStartLiveCleanup } from "./direct-start-temp-home-cleanup.js";
11
+ import { resolveTsxBin } from "./resolve-tsx-bin.js";
12
+ const [home, readyFile, serverEntry, portStr] = process.argv.slice(2);
13
+ if (!home || !readyFile || !serverEntry || !portStr) {
14
+ console.error("usage: direct-start-interrupt-probe <home> <readyFile> <serverEntry> <port>");
15
+ process.exit(2);
16
+ }
17
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
+ const repoRoot = path.resolve(__dirname, "..", "..", "..");
19
+ const tsxBin = resolveTsxBin(repoRoot);
20
+ const liveCleanup = new DirectStartLiveCleanup();
21
+ function signalGroup(child, signal) {
22
+ if (child.pid === undefined)
23
+ return;
24
+ try {
25
+ process.kill(-child.pid, signal);
26
+ }
27
+ catch {
28
+ // ESRCH — already gone
29
+ }
30
+ }
31
+ function reapAllLiveCleanup() {
32
+ liveCleanup.reapAll(signalGroup);
33
+ }
34
+ liveCleanup.trackHome(home);
35
+ const child = spawn(tsxBin, [serverEntry], {
36
+ cwd: repoRoot,
37
+ env: {
38
+ ...process.env,
39
+ AGENT_DEALER_HOME: home,
40
+ AGENT_DEALER_ENV: "development",
41
+ PORT: portStr,
42
+ },
43
+ stdio: "ignore",
44
+ detached: true,
45
+ });
46
+ child.unref();
47
+ liveCleanup.addServer(child);
48
+ process.on("exit", reapAllLiveCleanup);
49
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
50
+ process.on(signal, () => {
51
+ reapAllLiveCleanup();
52
+ process.exit(1);
53
+ });
54
+ }
55
+ // Stay alive until the parent interrupts us. The ready file tells the parent the
56
+ // home path and that the signal handlers are registered.
57
+ fs.writeFileSync(readyFile, JSON.stringify({ home, pid: process.pid, serverLauncherPid: child.pid }));
58
+ setInterval(() => { }, 60_000);