@momentiq/dark-factory-cli 0.1.0-alpha.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/LICENSE +201 -0
- package/README.md +150 -0
- package/dist/adapters/_shared.d.ts +45 -0
- package/dist/adapters/_shared.d.ts.map +1 -0
- package/dist/adapters/_shared.js +200 -0
- package/dist/adapters/_shared.js.map +1 -0
- package/dist/adapters/codex-sdk.d.ts +279 -0
- package/dist/adapters/codex-sdk.d.ts.map +1 -0
- package/dist/adapters/codex-sdk.js +930 -0
- package/dist/adapters/codex-sdk.js.map +1 -0
- package/dist/adapters/critic-result-schema.d.ts +215 -0
- package/dist/adapters/critic-result-schema.d.ts.map +1 -0
- package/dist/adapters/critic-result-schema.js +153 -0
- package/dist/adapters/critic-result-schema.js.map +1 -0
- package/dist/adapters/critic.d.ts +62 -0
- package/dist/adapters/critic.d.ts.map +1 -0
- package/dist/adapters/critic.js +79 -0
- package/dist/adapters/critic.js.map +1 -0
- package/dist/adapters/cursor-sdk.d.ts +153 -0
- package/dist/adapters/cursor-sdk.d.ts.map +1 -0
- package/dist/adapters/cursor-sdk.js +818 -0
- package/dist/adapters/cursor-sdk.js.map +1 -0
- package/dist/adapters/gemini-sdk.d.ts +113 -0
- package/dist/adapters/gemini-sdk.d.ts.map +1 -0
- package/dist/adapters/gemini-sdk.js +532 -0
- package/dist/adapters/gemini-sdk.js.map +1 -0
- package/dist/adapters/grok-direct-sdk.d.ts +148 -0
- package/dist/adapters/grok-direct-sdk.d.ts.map +1 -0
- package/dist/adapters/grok-direct-sdk.js +694 -0
- package/dist/adapters/grok-direct-sdk.js.map +1 -0
- package/dist/branch-protection/audit_branch_protection.py +759 -0
- package/dist/branch-protection/index.d.ts +25 -0
- package/dist/branch-protection/index.d.ts.map +1 -0
- package/dist/branch-protection/index.js +70 -0
- package/dist/branch-protection/index.js.map +1 -0
- package/dist/branch-protection/spec-default.yaml +314 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +581 -0
- package/dist/cli.js.map +1 -0
- package/dist/cycle-doc-validator/index.d.ts +43 -0
- package/dist/cycle-doc-validator/index.d.ts.map +1 -0
- package/dist/cycle-doc-validator/index.js +69 -0
- package/dist/cycle-doc-validator/index.js.map +1 -0
- package/dist/cycle-doc-validator/validate_cycle_doc.py +1260 -0
- package/dist/cycle-tracker-sync/attribute_pr_cycle_ref.py +384 -0
- package/dist/cycle-tracker-sync/index.d.ts +20 -0
- package/dist/cycle-tracker-sync/index.d.ts.map +1 -0
- package/dist/cycle-tracker-sync/index.js +71 -0
- package/dist/cycle-tracker-sync/index.js.map +1 -0
- package/dist/cycle-tracker-sync/sync_cycle_trackers.py +1093 -0
- package/dist/evidence/audit-trail.d.ts +59 -0
- package/dist/evidence/audit-trail.d.ts.map +1 -0
- package/dist/evidence/audit-trail.js +283 -0
- package/dist/evidence/audit-trail.js.map +1 -0
- package/dist/evidence/index.d.ts +4 -0
- package/dist/evidence/index.d.ts.map +1 -0
- package/dist/evidence/index.js +29 -0
- package/dist/evidence/index.js.map +1 -0
- package/dist/evidence/per-sha.d.ts +13 -0
- package/dist/evidence/per-sha.d.ts.map +1 -0
- package/dist/evidence/per-sha.js +97 -0
- package/dist/evidence/per-sha.js.map +1 -0
- package/dist/evidence/quality-gates.d.ts +19 -0
- package/dist/evidence/quality-gates.d.ts.map +1 -0
- package/dist/evidence/quality-gates.js +212 -0
- package/dist/evidence/quality-gates.js.map +1 -0
- package/dist/git.d.ts +40 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +414 -0
- package/dist/git.js.map +1 -0
- package/dist/glob.d.ts +4 -0
- package/dist/glob.d.ts.map +1 -0
- package/dist/glob.js +99 -0
- package/dist/glob.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/paths.d.ts +12 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +42 -0
- package/dist/paths.js.map +1 -0
- package/dist/policy/baseline.d.ts +15 -0
- package/dist/policy/baseline.d.ts.map +1 -0
- package/dist/policy/baseline.js +115 -0
- package/dist/policy/baseline.js.map +1 -0
- package/dist/policy/config.d.ts +64 -0
- package/dist/policy/config.d.ts.map +1 -0
- package/dist/policy/config.js +363 -0
- package/dist/policy/config.js.map +1 -0
- package/dist/policy/gate.d.ts +80 -0
- package/dist/policy/gate.d.ts.map +1 -0
- package/dist/policy/gate.js +1019 -0
- package/dist/policy/gate.js.map +1 -0
- package/dist/policy/index.d.ts +7 -0
- package/dist/policy/index.d.ts.map +1 -0
- package/dist/policy/index.js +14 -0
- package/dist/policy/index.js.map +1 -0
- package/dist/policy/merge-queue.d.ts +183 -0
- package/dist/policy/merge-queue.d.ts.map +1 -0
- package/dist/policy/merge-queue.js +310 -0
- package/dist/policy/merge-queue.js.map +1 -0
- package/dist/policy/profile.d.ts +98 -0
- package/dist/policy/profile.d.ts.map +1 -0
- package/dist/policy/profile.js +156 -0
- package/dist/policy/profile.js.map +1 -0
- package/dist/policy/tdd-classifier.d.ts +18 -0
- package/dist/policy/tdd-classifier.d.ts.map +1 -0
- package/dist/policy/tdd-classifier.js +79 -0
- package/dist/policy/tdd-classifier.js.map +1 -0
- package/dist/prompt.d.ts +13 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +175 -0
- package/dist/prompt.js.map +1 -0
- package/dist/report.d.ts +88 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +376 -0
- package/dist/report.js.map +1 -0
- package/dist/runner.d.ts +30 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +456 -0
- package/dist/runner.js.map +1 -0
- package/dist/security.d.ts +2 -0
- package/dist/security.d.ts.map +1 -0
- package/dist/security.js +19 -0
- package/dist/security.js.map +1 -0
- package/dist/trusted-surface/rebind.d.ts +10 -0
- package/dist/trusted-surface/rebind.d.ts.map +1 -0
- package/dist/trusted-surface/rebind.js +154 -0
- package/dist/trusted-surface/rebind.js.map +1 -0
- package/package.json +78 -0
|
@@ -0,0 +1,818 @@
|
|
|
1
|
+
import { Agent } from "@cursor/sdk";
|
|
2
|
+
import { compileCriticPrompt } from "../prompt.js";
|
|
3
|
+
import { parseCriticResult, } from "@momentiq/dark-factory-schemas";
|
|
4
|
+
import { buildErrorResult, mergeAdapterMetadata, normalizeCriticEcho, parseAssistantJson, shouldEnableCursorSandbox, writeRedactedDiagnostic, } from "./_shared.js";
|
|
5
|
+
// Re-export for backwards compatibility — `normalizeCriticEcho` originally
|
|
6
|
+
// lived here. Existing imports from `cursor-sdk.js` (e.g. cursor-adapter
|
|
7
|
+
// tests) continue to work after the move to `_shared.ts` (issue #1484).
|
|
8
|
+
export { normalizeCriticEcho } from "./_shared.js";
|
|
9
|
+
export const CURSOR_SDK_ADAPTER_ID = "cursor-sdk";
|
|
10
|
+
export const CURSOR_API_KEY_ENV = "CURSOR_API_KEY";
|
|
11
|
+
// Cycle 322.1 — bounded retry policy for the Cursor SDK adapter.
|
|
12
|
+
//
|
|
13
|
+
// The Cursor SDK delivers terminal upstream failures as a normal
|
|
14
|
+
// `RunResult.status === "error"` (no thrown exception). The richer
|
|
15
|
+
// signal — `LocalRunStreamResultEvent.errorCode` and the streamed
|
|
16
|
+
// `SDKStatusMessage` — is what tells operators whether the failure is
|
|
17
|
+
// transient (capacity_exceeded / upstream_timeout) or permanent
|
|
18
|
+
// (auth_failed / quota_exceeded). Capturing both and retrying ONLY
|
|
19
|
+
// transient failures replaces the prior "any terminal error → gate
|
|
20
|
+
// blocks → emergency bypass" anti-pattern with a sanctioned, bounded,
|
|
21
|
+
// observable recovery path.
|
|
22
|
+
//
|
|
23
|
+
// Empirical signal: 26/27 transient terminal-error runs observed
|
|
24
|
+
// over a 4-day window succeeded on retry with the same prompt
|
|
25
|
+
// within 1–5 minutes. The fixed `[5s, 15s]` schedule covers that
|
|
26
|
+
// recovery window without trading too much wall-clock for tail
|
|
27
|
+
// success.
|
|
28
|
+
//
|
|
29
|
+
// Total budget: 20s across 2 retries (3 attempts total). If a vendor
|
|
30
|
+
// outage outlives 20s, the gate blocks deterministically with an
|
|
31
|
+
// actionable `errorCode` instead of returning APPROVED on stale data.
|
|
32
|
+
export const RETRY_BACKOFF_MS = Object.freeze([5_000, 15_000]);
|
|
33
|
+
// Error codes that are NOT retryable. A terminal failure matching one
|
|
34
|
+
// of these proceeds directly to error-result construction without a
|
|
35
|
+
// retry. These are permanent failures where retrying wastes budget
|
|
36
|
+
// AND can mask the real fault (e.g., a wrong API key would silently
|
|
37
|
+
// burn 20s of retries before surfacing the auth issue).
|
|
38
|
+
//
|
|
39
|
+
// The set is intentionally narrow — anything not on this list is
|
|
40
|
+
// treated as retryable when accompanied by a runId (indicating the
|
|
41
|
+
// SDK accepted the request and the failure happened upstream).
|
|
42
|
+
export const PERMANENT_ERROR_CODES = new Set([
|
|
43
|
+
"auth_failed",
|
|
44
|
+
"invalid_api_key",
|
|
45
|
+
"quota_exceeded",
|
|
46
|
+
"model_not_found",
|
|
47
|
+
"content_policy_violation",
|
|
48
|
+
"invalid_request",
|
|
49
|
+
"context_length_exceeded",
|
|
50
|
+
]);
|
|
51
|
+
/**
|
|
52
|
+
* Cycle 322.1 — Pure retry-loop runner.
|
|
53
|
+
*
|
|
54
|
+
* Drives a sequence of `attempt(idx)` calls under the
|
|
55
|
+
* {@link RETRY_BACKOFF_MS} schedule, dispatching on the returned
|
|
56
|
+
* {@link AttemptOutcome}:
|
|
57
|
+
* - `success` / `permanent_failure` → return immediately, no more
|
|
58
|
+
* attempts.
|
|
59
|
+
* - `retryable_failure` → record the failure and (if budget
|
|
60
|
+
* remains) sleep + try again.
|
|
61
|
+
*
|
|
62
|
+
* Honors `signal` between attempts and during backoff sleeps; on
|
|
63
|
+
* abort, builds a terminal result via `buildExhausted` with the
|
|
64
|
+
* last failure context (so callers can surface "what was the last
|
|
65
|
+
* upstream error" even when cancellation cut the loop short).
|
|
66
|
+
*
|
|
67
|
+
* Extracted as a free function so:
|
|
68
|
+
* - 322.2 Gemini and 322.3 Grok adapters inherit the retry pattern
|
|
69
|
+
* without copy-paste drift.
|
|
70
|
+
* - The loop is unit-testable with scripted outcomes + a mock
|
|
71
|
+
* `sleep` (see `tests/cursor-retry-loop.test.ts`) — no SDK mock
|
|
72
|
+
* required.
|
|
73
|
+
*/
|
|
74
|
+
export async function runRetryLoop(args) {
|
|
75
|
+
const maxAttempts = RETRY_BACKOFF_MS.length + 1;
|
|
76
|
+
const sleep = args.sleep ?? sleepForRetry;
|
|
77
|
+
let attempt = 0;
|
|
78
|
+
let lastFailure = null;
|
|
79
|
+
let aborted = false;
|
|
80
|
+
while (attempt < maxAttempts) {
|
|
81
|
+
if (args.signal?.aborted) {
|
|
82
|
+
aborted = true;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
const outcome = await args.attempt(attempt);
|
|
86
|
+
if (outcome.kind === "success")
|
|
87
|
+
return outcome.result;
|
|
88
|
+
if (outcome.kind === "permanent_failure")
|
|
89
|
+
return outcome.result;
|
|
90
|
+
lastFailure = outcome;
|
|
91
|
+
// Sleep only if we still have retries left. After the last
|
|
92
|
+
// attempt (idx === RETRY_BACKOFF_MS.length), no sleep — fall out
|
|
93
|
+
// to exhausted path immediately.
|
|
94
|
+
if (attempt < RETRY_BACKOFF_MS.length) {
|
|
95
|
+
try {
|
|
96
|
+
await sleep(attempt, args.signal);
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
if (err.name === "AbortError") {
|
|
100
|
+
aborted = true;
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
attempt++;
|
|
107
|
+
}
|
|
108
|
+
return args.buildExhausted({ last: lastFailure, totalAttempts: attempt, aborted });
|
|
109
|
+
}
|
|
110
|
+
export class CursorSdkAdapter {
|
|
111
|
+
options;
|
|
112
|
+
id = CURSOR_SDK_ADAPTER_ID;
|
|
113
|
+
// Cycle 322.2 Component 3 — declared so the CLI's
|
|
114
|
+
// `maybeReexecUnderDoppler` can walk required critics' adapters and
|
|
115
|
+
// re-exec under `doppler run` when a required critic key is missing.
|
|
116
|
+
requiredEnvVars = [CURSOR_API_KEY_ENV];
|
|
117
|
+
constructor(options = {}) {
|
|
118
|
+
this.options = options;
|
|
119
|
+
}
|
|
120
|
+
async review(packet, critic, options) {
|
|
121
|
+
// Cycle 322.1 — bounded retry across the Cursor SDK's transient
|
|
122
|
+
// failure surface. Total budget = sum(RETRY_BACKOFF_MS) = 20s
|
|
123
|
+
// across 2 retries (3 attempts). Per-attempt telemetry is emitted
|
|
124
|
+
// inside `attemptReview` so the loop itself stays pure.
|
|
125
|
+
return runRetryLoop({
|
|
126
|
+
attempt: (idx) => this.attemptReview(packet, critic, options, idx),
|
|
127
|
+
...(options.signal !== undefined ? { signal: options.signal } : {}),
|
|
128
|
+
buildExhausted: ({ last, totalAttempts, aborted }) => {
|
|
129
|
+
// `totalAttempts` is the post-increment attempt index when the
|
|
130
|
+
// loop ended naturally (= RETRY_BACKOFF_MS.length + 1 = 3 on
|
|
131
|
+
// full exhaustion). On abort it's the index reached before
|
|
132
|
+
// the abort. Either way the highest *retry index* used is
|
|
133
|
+
// `totalAttempts - 1` (clamped to ≥ 0).
|
|
134
|
+
const retriesUsed = Math.max(0, totalAttempts - 1);
|
|
135
|
+
const summary = aborted
|
|
136
|
+
? last
|
|
137
|
+
? `cursor SDK run aborted after ${retriesUsed} retries: ${last.message}`
|
|
138
|
+
: "cursor SDK run aborted before any attempt completed"
|
|
139
|
+
: last
|
|
140
|
+
? `cursor SDK run failed after ${retriesUsed} retries: ${last.message}`
|
|
141
|
+
: // Shouldn't happen — loop exhaustion without a failure
|
|
142
|
+
// means attempt() returned non-retryable for all attempts,
|
|
143
|
+
// which would have returned early. Defensive fallback.
|
|
144
|
+
"cursor SDK run failed with no captured failure metadata";
|
|
145
|
+
return buildErrorResult({
|
|
146
|
+
critic,
|
|
147
|
+
message: summary,
|
|
148
|
+
retryable: true,
|
|
149
|
+
...(last?.errorCode != null ? { code: last.errorCode } : {}),
|
|
150
|
+
retryCount: retriesUsed,
|
|
151
|
+
...(last?.agentId !== null && last?.agentId !== undefined ? { agentId: last.agentId } : {}),
|
|
152
|
+
...(last?.runId !== null && last?.runId !== undefined ? { runId: last.runId } : {}),
|
|
153
|
+
});
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
// Cycle 322.1 — one attempt. Pure-ish: emits its own telemetry
|
|
158
|
+
// event for terminal outcomes (success, permanent_failure,
|
|
159
|
+
// retryable_failure), returns a tagged AttemptOutcome the outer
|
|
160
|
+
// loop dispatches on. The `attemptIdx` is woven into telemetry
|
|
161
|
+
// (`retryCount`) and into the success result (`retryCount` on
|
|
162
|
+
// `critic_run_finished`).
|
|
163
|
+
async attemptReview(packet, critic, options, attemptIdx) {
|
|
164
|
+
const apiKey = this.options.apiKey ?? process.env["CURSOR_API_KEY"];
|
|
165
|
+
if (!apiKey) {
|
|
166
|
+
// Missing key is a permanent failure regardless of attempt — no
|
|
167
|
+
// retry can fix a missing secret. Skip the retry policy and
|
|
168
|
+
// return immediately with a permanent envelope.
|
|
169
|
+
return {
|
|
170
|
+
kind: "permanent_failure",
|
|
171
|
+
errorCode: null,
|
|
172
|
+
statusMessage: null,
|
|
173
|
+
result: buildErrorResult({
|
|
174
|
+
critic,
|
|
175
|
+
message: "CURSOR_API_KEY is not set; cannot run Cursor critic",
|
|
176
|
+
retryable: false,
|
|
177
|
+
retryCount: attemptIdx,
|
|
178
|
+
}),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const prompt = compileCriticPrompt({
|
|
182
|
+
packet,
|
|
183
|
+
critic,
|
|
184
|
+
blockingSeverities: options.blockingSeverities,
|
|
185
|
+
treatDiffAsUntrusted: true,
|
|
186
|
+
});
|
|
187
|
+
const startMs = Date.now();
|
|
188
|
+
// Emit `critic_run_started` only on the first attempt; retries
|
|
189
|
+
// are NOT independent reviews, they're continuations of the
|
|
190
|
+
// single review the runner kicked off, so double-counting starts
|
|
191
|
+
// would mis-attribute attempt counts in `agent-review-stats`.
|
|
192
|
+
if (attemptIdx === 0) {
|
|
193
|
+
options.emit?.({
|
|
194
|
+
ts: new Date().toISOString(),
|
|
195
|
+
event: "critic_run_started",
|
|
196
|
+
commit: packet.commit.sha,
|
|
197
|
+
criticId: critic.id,
|
|
198
|
+
adapter: this.id,
|
|
199
|
+
model: critic.model.id,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
let agent;
|
|
203
|
+
let agentId;
|
|
204
|
+
let runId;
|
|
205
|
+
let assistantText = "";
|
|
206
|
+
// Capture SDKStatusMessage events as they stream; the last one
|
|
207
|
+
// before terminal status carries the SDK's own diagnostic when an
|
|
208
|
+
// upstream incident is in progress.
|
|
209
|
+
let lastStatusMessage = null;
|
|
210
|
+
try {
|
|
211
|
+
const localOptions = buildLocalOptions(packet.repoRoot);
|
|
212
|
+
agent = await Agent.create({
|
|
213
|
+
apiKey,
|
|
214
|
+
model: buildModelSelection(critic),
|
|
215
|
+
local: localOptions,
|
|
216
|
+
});
|
|
217
|
+
agentId = agent.id;
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
const e = err;
|
|
221
|
+
options.emit?.({
|
|
222
|
+
ts: new Date().toISOString(),
|
|
223
|
+
event: "critic_run_error",
|
|
224
|
+
commit: packet.commit.sha,
|
|
225
|
+
criticId: critic.id,
|
|
226
|
+
adapter: this.id,
|
|
227
|
+
model: critic.model.id,
|
|
228
|
+
durationMs: Date.now() - startMs,
|
|
229
|
+
error: e.message,
|
|
230
|
+
status: "startup_failure",
|
|
231
|
+
retryCount: attemptIdx,
|
|
232
|
+
});
|
|
233
|
+
// Adapter init failure has no runId, so `shouldRetryRunFailure`
|
|
234
|
+
// would deny retry. Treat as retryable_failure here (the
|
|
235
|
+
// network blip case is genuine) — the outer loop respects
|
|
236
|
+
// attempt count and stops naturally; without a runId there's
|
|
237
|
+
// no upstream state to be ambiguous about.
|
|
238
|
+
return {
|
|
239
|
+
kind: "retryable_failure",
|
|
240
|
+
errorCode: null,
|
|
241
|
+
statusMessage: null,
|
|
242
|
+
message: `cursor SDK startup failed: ${e.message}`,
|
|
243
|
+
runId: null,
|
|
244
|
+
agentId: agentId ?? null,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
let runStatus;
|
|
248
|
+
let terminalResult = null;
|
|
249
|
+
try {
|
|
250
|
+
const sendable = agent;
|
|
251
|
+
const run = (await sendable.send(prompt.text));
|
|
252
|
+
runId = run.id;
|
|
253
|
+
for await (const event of run.stream()) {
|
|
254
|
+
if (options.signal?.aborted)
|
|
255
|
+
break;
|
|
256
|
+
const ids = extractIds(event);
|
|
257
|
+
if (!agentId && ids.agentId)
|
|
258
|
+
agentId = ids.agentId;
|
|
259
|
+
if (!runId && ids.runId)
|
|
260
|
+
runId = ids.runId;
|
|
261
|
+
const text = extractAssistantText(event);
|
|
262
|
+
if (text)
|
|
263
|
+
assistantText += text;
|
|
264
|
+
// Cycle 322.1 — capture status-message stream events. Each
|
|
265
|
+
// overwrites the previous so we end with the LAST status the
|
|
266
|
+
// SDK emitted, which is the most relevant signal for the
|
|
267
|
+
// terminal outcome.
|
|
268
|
+
const status = extractStatusMessage(event);
|
|
269
|
+
if (status)
|
|
270
|
+
lastStatusMessage = status;
|
|
271
|
+
}
|
|
272
|
+
terminalResult = await run.wait();
|
|
273
|
+
runStatus = terminalResult.status;
|
|
274
|
+
const statusError = checkRunFinished(runStatus);
|
|
275
|
+
if (statusError)
|
|
276
|
+
throw new Error(statusError);
|
|
277
|
+
}
|
|
278
|
+
catch (err) {
|
|
279
|
+
const e = err;
|
|
280
|
+
const errorCode = extractRunErrorCode(terminalResult);
|
|
281
|
+
// Apply the retry policy — does this failure pattern allow
|
|
282
|
+
// another attempt? If not, return a permanent envelope; the
|
|
283
|
+
// outer loop will surface this immediately without sleeping.
|
|
284
|
+
const retryAllowed = shouldRetryRunFailure({
|
|
285
|
+
result: terminalResult,
|
|
286
|
+
errorCode,
|
|
287
|
+
runId: runId ?? null,
|
|
288
|
+
});
|
|
289
|
+
options.emit?.({
|
|
290
|
+
ts: new Date().toISOString(),
|
|
291
|
+
event: "critic_run_error",
|
|
292
|
+
commit: packet.commit.sha,
|
|
293
|
+
criticId: critic.id,
|
|
294
|
+
adapter: this.id,
|
|
295
|
+
model: critic.model.id,
|
|
296
|
+
...(agentId !== undefined ? { agentId } : {}),
|
|
297
|
+
...(runId !== undefined ? { runId } : {}),
|
|
298
|
+
durationMs: Date.now() - startMs,
|
|
299
|
+
error: e.message,
|
|
300
|
+
status: "run_failure",
|
|
301
|
+
retryCount: attemptIdx,
|
|
302
|
+
...(errorCode !== null ? { errorCode } : {}),
|
|
303
|
+
...(lastStatusMessage !== null ? { statusMessage: lastStatusMessage } : {}),
|
|
304
|
+
});
|
|
305
|
+
await disposeAgent(agent);
|
|
306
|
+
if (!retryAllowed) {
|
|
307
|
+
return {
|
|
308
|
+
kind: "permanent_failure",
|
|
309
|
+
errorCode,
|
|
310
|
+
statusMessage: lastStatusMessage,
|
|
311
|
+
result: buildErrorResult({
|
|
312
|
+
critic,
|
|
313
|
+
message: `cursor SDK run failed (permanent): ${e.message}`,
|
|
314
|
+
retryable: false,
|
|
315
|
+
...(agentId !== undefined ? { agentId } : {}),
|
|
316
|
+
...(runId !== undefined ? { runId } : {}),
|
|
317
|
+
...(errorCode !== null ? { code: errorCode } : {}),
|
|
318
|
+
retryCount: attemptIdx,
|
|
319
|
+
}),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
kind: "retryable_failure",
|
|
324
|
+
errorCode,
|
|
325
|
+
statusMessage: lastStatusMessage,
|
|
326
|
+
message: `cursor SDK run failed: ${e.message}`,
|
|
327
|
+
runId: runId ?? null,
|
|
328
|
+
agentId: agentId ?? null,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
await disposeAgent(agent);
|
|
332
|
+
const parseOutcome = parseAssistantJson(assistantText);
|
|
333
|
+
if (!parseOutcome.ok) {
|
|
334
|
+
const diagPath = writeRedactedDiagnostic({
|
|
335
|
+
diagnosticsDir: options.diagnosticsDir,
|
|
336
|
+
criticId: critic.id,
|
|
337
|
+
commit: packet.commit.sha,
|
|
338
|
+
rawText: assistantText,
|
|
339
|
+
});
|
|
340
|
+
options.emit?.({
|
|
341
|
+
ts: new Date().toISOString(),
|
|
342
|
+
event: "critic_run_error",
|
|
343
|
+
commit: packet.commit.sha,
|
|
344
|
+
criticId: critic.id,
|
|
345
|
+
adapter: this.id,
|
|
346
|
+
model: critic.model.id,
|
|
347
|
+
...(agentId !== undefined ? { agentId } : {}),
|
|
348
|
+
...(runId !== undefined ? { runId } : {}),
|
|
349
|
+
durationMs: Date.now() - startMs,
|
|
350
|
+
error: `invalid critic JSON: ${parseOutcome.message}`,
|
|
351
|
+
status: "invalid_json",
|
|
352
|
+
retryCount: attemptIdx,
|
|
353
|
+
});
|
|
354
|
+
// JSON parse failure is a permanent failure for this attempt —
|
|
355
|
+
// the SDK succeeded but the model returned malformed output.
|
|
356
|
+
// Retrying typically produces the same malformed output (the
|
|
357
|
+
// model isn't aware of its own parse failure), and a retry
|
|
358
|
+
// budget burnt on bad JSON is budget not available for a real
|
|
359
|
+
// transient upstream incident.
|
|
360
|
+
return {
|
|
361
|
+
kind: "permanent_failure",
|
|
362
|
+
errorCode: null,
|
|
363
|
+
statusMessage: lastStatusMessage,
|
|
364
|
+
result: buildErrorResult({
|
|
365
|
+
critic,
|
|
366
|
+
message: `cursor critic returned invalid JSON: ${parseOutcome.message}`,
|
|
367
|
+
retryable: false,
|
|
368
|
+
...(diagPath !== undefined ? { rawSamplePath: diagPath } : {}),
|
|
369
|
+
...(agentId !== undefined ? { agentId } : {}),
|
|
370
|
+
...(runId !== undefined ? { runId } : {}),
|
|
371
|
+
retryCount: attemptIdx,
|
|
372
|
+
}),
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
let result;
|
|
376
|
+
try {
|
|
377
|
+
const baseRaw = parseOutcome.value;
|
|
378
|
+
const normalized = normalizeCriticEcho(baseRaw);
|
|
379
|
+
const runtimeModel = extractRuntimeModel(agent);
|
|
380
|
+
const enriched = mergeAdapterMetadata(normalized, {
|
|
381
|
+
critic,
|
|
382
|
+
...(runtimeModel !== undefined ? { runtimeModel } : {}),
|
|
383
|
+
...(agentId !== undefined ? { agentId } : {}),
|
|
384
|
+
...(runId !== undefined ? { runId } : {}),
|
|
385
|
+
});
|
|
386
|
+
result = parseCriticResult(enriched, options.blockingSeverities);
|
|
387
|
+
}
|
|
388
|
+
catch (err) {
|
|
389
|
+
const e = err;
|
|
390
|
+
const diagPath = writeRedactedDiagnostic({
|
|
391
|
+
diagnosticsDir: options.diagnosticsDir,
|
|
392
|
+
criticId: critic.id,
|
|
393
|
+
commit: packet.commit.sha,
|
|
394
|
+
rawText: assistantText,
|
|
395
|
+
});
|
|
396
|
+
options.emit?.({
|
|
397
|
+
ts: new Date().toISOString(),
|
|
398
|
+
event: "critic_run_error",
|
|
399
|
+
commit: packet.commit.sha,
|
|
400
|
+
criticId: critic.id,
|
|
401
|
+
adapter: this.id,
|
|
402
|
+
model: critic.model.id,
|
|
403
|
+
...(agentId !== undefined ? { agentId } : {}),
|
|
404
|
+
...(runId !== undefined ? { runId } : {}),
|
|
405
|
+
durationMs: Date.now() - startMs,
|
|
406
|
+
error: `schema validation failed: ${e.message}`,
|
|
407
|
+
status: "schema_violation",
|
|
408
|
+
retryCount: attemptIdx,
|
|
409
|
+
});
|
|
410
|
+
return {
|
|
411
|
+
kind: "permanent_failure",
|
|
412
|
+
errorCode: null,
|
|
413
|
+
statusMessage: lastStatusMessage,
|
|
414
|
+
result: buildErrorResult({
|
|
415
|
+
critic,
|
|
416
|
+
message: `cursor critic JSON failed schema validation: ${e.message}`,
|
|
417
|
+
retryable: false,
|
|
418
|
+
...(diagPath !== undefined ? { rawSamplePath: diagPath } : {}),
|
|
419
|
+
...(agentId !== undefined ? { agentId } : {}),
|
|
420
|
+
...(runId !== undefined ? { runId } : {}),
|
|
421
|
+
retryCount: attemptIdx,
|
|
422
|
+
}),
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
const durationMs = Date.now() - startMs;
|
|
426
|
+
// Replace the critic's echoed validation block with the truth: the
|
|
427
|
+
// deterministic evidence the packet carried. The critic's echo is
|
|
428
|
+
// accepted leniently above for legacy/varying responses but the
|
|
429
|
+
// authoritative copy comes from us.
|
|
430
|
+
const enriched = {
|
|
431
|
+
...result,
|
|
432
|
+
durationMs,
|
|
433
|
+
validation: {
|
|
434
|
+
qualityGateResults: packet.validation.evidence,
|
|
435
|
+
qualityGatesMissing: packet.validation.missing,
|
|
436
|
+
},
|
|
437
|
+
};
|
|
438
|
+
const blockerCount = enriched.findings.filter((f) => f.severity === "blocker").length;
|
|
439
|
+
const highCount = enriched.findings.filter((f) => f.severity === "high").length;
|
|
440
|
+
options.emit?.({
|
|
441
|
+
ts: new Date().toISOString(),
|
|
442
|
+
event: "critic_run_finished",
|
|
443
|
+
commit: packet.commit.sha,
|
|
444
|
+
criticId: critic.id,
|
|
445
|
+
adapter: this.id,
|
|
446
|
+
model: critic.model.id,
|
|
447
|
+
...(agentId !== undefined ? { agentId } : {}),
|
|
448
|
+
...(runId !== undefined ? { runId } : {}),
|
|
449
|
+
durationMs,
|
|
450
|
+
...(enriched.verdict !== undefined ? { verdict: enriched.verdict } : {}),
|
|
451
|
+
findingCount: enriched.findings.length,
|
|
452
|
+
blockerCount,
|
|
453
|
+
highCount,
|
|
454
|
+
status: runStatus ?? "complete",
|
|
455
|
+
retryCount: attemptIdx,
|
|
456
|
+
});
|
|
457
|
+
return { kind: "success", result: enriched };
|
|
458
|
+
}
|
|
459
|
+
async doctor(critic) {
|
|
460
|
+
const checks = [];
|
|
461
|
+
const apiKey = this.options.apiKey ?? process.env["CURSOR_API_KEY"];
|
|
462
|
+
checks.push({
|
|
463
|
+
name: "cursor_api_key",
|
|
464
|
+
passed: Boolean(apiKey),
|
|
465
|
+
detail: apiKey ? "CURSOR_API_KEY present" : "CURSOR_API_KEY missing",
|
|
466
|
+
...(apiKey ? {} : { remediation: "export CURSOR_API_KEY=..." }),
|
|
467
|
+
});
|
|
468
|
+
let cursorModule = {};
|
|
469
|
+
try {
|
|
470
|
+
cursorModule = (await import("@cursor/sdk"));
|
|
471
|
+
}
|
|
472
|
+
catch (err) {
|
|
473
|
+
checks.push({
|
|
474
|
+
name: "cursor_sdk_loaded",
|
|
475
|
+
passed: false,
|
|
476
|
+
detail: `failed to import @cursor/sdk: ${err.message}`,
|
|
477
|
+
remediation: "make agent-review-deps && make agent-review-build",
|
|
478
|
+
});
|
|
479
|
+
return checks;
|
|
480
|
+
}
|
|
481
|
+
checks.push({
|
|
482
|
+
name: "cursor_sdk_loaded",
|
|
483
|
+
passed: true,
|
|
484
|
+
detail: "@cursor/sdk imported",
|
|
485
|
+
});
|
|
486
|
+
const cursor = cursorModule["Cursor"];
|
|
487
|
+
const list = cursor?.models?.list;
|
|
488
|
+
if (typeof list !== "function") {
|
|
489
|
+
checks.push({
|
|
490
|
+
name: "cursor_model_listing",
|
|
491
|
+
passed: false,
|
|
492
|
+
detail: "Cursor.models.list is not exposed by this SDK version; cannot verify model id",
|
|
493
|
+
remediation: "Run the Cursor SDK spike (make agent-review-spike) and update the doctor check to match the SDK shape.",
|
|
494
|
+
});
|
|
495
|
+
return checks;
|
|
496
|
+
}
|
|
497
|
+
if (!apiKey)
|
|
498
|
+
return checks;
|
|
499
|
+
try {
|
|
500
|
+
const models = await list();
|
|
501
|
+
const ids = models.map((m) => m.id ?? "");
|
|
502
|
+
const matched = ids.includes(critic.model.id);
|
|
503
|
+
checks.push({
|
|
504
|
+
name: "cursor_model_id",
|
|
505
|
+
passed: matched,
|
|
506
|
+
detail: matched
|
|
507
|
+
? `model ${critic.model.id} available`
|
|
508
|
+
: `model ${critic.model.id} not in available list (${ids.slice(0, 8).join(", ")}${ids.length > 8 ? "..." : ""})`,
|
|
509
|
+
...(matched ? {} : { remediation: "update .agent-review/config.json critic.model.id" }),
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
catch (err) {
|
|
513
|
+
checks.push({
|
|
514
|
+
name: "cursor_model_id",
|
|
515
|
+
passed: false,
|
|
516
|
+
detail: `Cursor.models.list() failed: ${err.message}`,
|
|
517
|
+
remediation: "verify CURSOR_API_KEY and network connectivity",
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
return checks;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
function buildLocalOptions(cwd) {
|
|
524
|
+
// Empty settingSources isolates the critic from project/user/team Cursor settings
|
|
525
|
+
// so reviews are reproducible across workstations.
|
|
526
|
+
// sandboxOptions.enabled is the SDK's defense-in-depth toggle — the critic
|
|
527
|
+
// must never write files locally even if a malicious diff convinces the
|
|
528
|
+
// model to try. On CI runners (#1577) the SDK's sandbox primitive is
|
|
529
|
+
// unavailable; `shouldEnableCursorSandbox` (in `_shared.ts`) detects that
|
|
530
|
+
// environment via `CI` / `GITHUB_ACTIONS` env vars and disables the toggle
|
|
531
|
+
// so the SDK doesn't fail the run with "sandboxing not supported".
|
|
532
|
+
return {
|
|
533
|
+
cwd,
|
|
534
|
+
settingSources: [],
|
|
535
|
+
sandboxOptions: { enabled: shouldEnableCursorSandbox() },
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
// Pass model id AND configured params to the Cursor SDK. Without this, the
|
|
539
|
+
// SDK falls back to the model's default variant (e.g., gpt-5.5 default is
|
|
540
|
+
// reasoning=medium even when config asks for extra-high), and the critic runs
|
|
541
|
+
// at a weaker tier than configured. The reviewer-metadata echo would still
|
|
542
|
+
// claim the configured params, so the artifact would silently lie.
|
|
543
|
+
//
|
|
544
|
+
// SDK shape (`ModelParameterValue`) requires `value: string`; our schema allows
|
|
545
|
+
// `string | number | boolean` so we coerce here. Booleans serialize to
|
|
546
|
+
// "true"/"false" matching Cursor's model-list values.
|
|
547
|
+
export function buildModelSelection(critic) {
|
|
548
|
+
const sel = { id: critic.model.id };
|
|
549
|
+
if (critic.model.params && critic.model.params.length > 0) {
|
|
550
|
+
sel.params = critic.model.params.map((p) => ({ id: p.id, value: String(p.value) }));
|
|
551
|
+
}
|
|
552
|
+
return sel;
|
|
553
|
+
}
|
|
554
|
+
// Extract only ASSISTANT message text from the stream. The SDK also emits
|
|
555
|
+
// `thinking`, `status`, `tool_call`, `task`, etc.; capturing those pollutes the
|
|
556
|
+
// output and breaks JSON parsing. Empirical SDK shapes are pinned in
|
|
557
|
+
// fixtures/spike-2026-05-03.json.
|
|
558
|
+
function extractAssistantText(event) {
|
|
559
|
+
if (typeof event !== "object" || event === null)
|
|
560
|
+
return "";
|
|
561
|
+
const e = event;
|
|
562
|
+
if (e["type"] !== "assistant")
|
|
563
|
+
return "";
|
|
564
|
+
const message = e["message"];
|
|
565
|
+
if (!message || typeof message !== "object")
|
|
566
|
+
return "";
|
|
567
|
+
const content = message["content"];
|
|
568
|
+
if (typeof content === "string")
|
|
569
|
+
return content;
|
|
570
|
+
if (!Array.isArray(content))
|
|
571
|
+
return "";
|
|
572
|
+
return content
|
|
573
|
+
.map((c) => {
|
|
574
|
+
if (!c || typeof c !== "object")
|
|
575
|
+
return "";
|
|
576
|
+
const block = c;
|
|
577
|
+
if (block["type"] !== "text")
|
|
578
|
+
return "";
|
|
579
|
+
const t = block["text"];
|
|
580
|
+
return typeof t === "string" ? t : "";
|
|
581
|
+
})
|
|
582
|
+
.join("");
|
|
583
|
+
}
|
|
584
|
+
function extractIds(event) {
|
|
585
|
+
if (typeof event !== "object" || event === null)
|
|
586
|
+
return {};
|
|
587
|
+
const e = event;
|
|
588
|
+
const out = {};
|
|
589
|
+
const aid = e["agent_id"] ?? e["agentId"];
|
|
590
|
+
if (typeof aid === "string")
|
|
591
|
+
out.agentId = aid;
|
|
592
|
+
const rid = e["run_id"] ?? e["runId"];
|
|
593
|
+
if (typeof rid === "string")
|
|
594
|
+
out.runId = rid;
|
|
595
|
+
return out;
|
|
596
|
+
}
|
|
597
|
+
// Returns null when the run terminated normally, an error string otherwise.
|
|
598
|
+
// Healthy terminal status is `"finished"` per spike fixture and SDK type
|
|
599
|
+
// definition (`"running" | "finished" | "error"`). Anything else means the
|
|
600
|
+
// SDK run did not complete — partial assistantText could still happen to
|
|
601
|
+
// parse as valid JSON and be treated as APPROVED, so the adapter must fail
|
|
602
|
+
// closed before parsing. Extracted for direct unit-testing.
|
|
603
|
+
export function checkRunFinished(runStatus) {
|
|
604
|
+
if (runStatus === "finished")
|
|
605
|
+
return null;
|
|
606
|
+
return `cursor SDK run terminated with status=${runStatus ?? "unknown"} (expected "finished")`;
|
|
607
|
+
}
|
|
608
|
+
// Read the SDK's resolved model selection off the agent. The SDK type
|
|
609
|
+
// definition (`SDKAgent.model: ModelSelection | undefined`) says this is
|
|
610
|
+
// "updated after each successful send({ model })", which means it reflects
|
|
611
|
+
// what the SDK actually accepted — not what we tried to send. Returns
|
|
612
|
+
// undefined when the SDK shape doesn't expose it (defensive fallback).
|
|
613
|
+
export function extractRuntimeModel(agent) {
|
|
614
|
+
if (!agent || typeof agent !== "object")
|
|
615
|
+
return undefined;
|
|
616
|
+
const model = agent.model;
|
|
617
|
+
if (!model || typeof model !== "object")
|
|
618
|
+
return undefined;
|
|
619
|
+
const id = model.id;
|
|
620
|
+
if (typeof id !== "string" || id.length === 0)
|
|
621
|
+
return undefined;
|
|
622
|
+
const paramsRaw = model.params;
|
|
623
|
+
if (!Array.isArray(paramsRaw))
|
|
624
|
+
return { id };
|
|
625
|
+
const params = [];
|
|
626
|
+
for (const p of paramsRaw) {
|
|
627
|
+
if (!p || typeof p !== "object")
|
|
628
|
+
continue;
|
|
629
|
+
const pid = p.id;
|
|
630
|
+
const pval = p.value;
|
|
631
|
+
if (typeof pid !== "string" || typeof pval !== "string")
|
|
632
|
+
continue;
|
|
633
|
+
params.push({ id: pid, value: pval });
|
|
634
|
+
}
|
|
635
|
+
return params.length > 0 ? { id, params } : { id };
|
|
636
|
+
}
|
|
637
|
+
async function disposeAgent(agent) {
|
|
638
|
+
if (!agent || typeof agent !== "object")
|
|
639
|
+
return;
|
|
640
|
+
const dispose = agent[Symbol.asyncDispose];
|
|
641
|
+
if (typeof dispose === "function") {
|
|
642
|
+
try {
|
|
643
|
+
await dispose.call(agent);
|
|
644
|
+
}
|
|
645
|
+
catch {
|
|
646
|
+
// best-effort
|
|
647
|
+
}
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
const close = agent.close;
|
|
651
|
+
if (typeof close === "function") {
|
|
652
|
+
try {
|
|
653
|
+
await close.call(agent);
|
|
654
|
+
}
|
|
655
|
+
catch {
|
|
656
|
+
// best-effort
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
// Cycle 322.1 — pure-function helpers for the retry loop. Extracted
|
|
661
|
+
// so the policy is testable without an SDK mock and so future
|
|
662
|
+
// adapters (322.2 Gemini, 322.3 Grok) can mirror the shape from a
|
|
663
|
+
// single source of truth instead of copy-pasting the patterns.
|
|
664
|
+
/**
|
|
665
|
+
* Pull structured status fields from an SDKStatusMessage stream event.
|
|
666
|
+
*
|
|
667
|
+
* The Cursor SDK streams `SDKStatusMessage` events before the terminal
|
|
668
|
+
* RunResult; when an upstream incident is in progress these carry the
|
|
669
|
+
* SDK's own human-readable explanation (e.g.,
|
|
670
|
+
* `{ status: "error", message: "Upstream model gpt-5.5 returned
|
|
671
|
+
* capacity_exceeded after retry policy exhausted" }`). The runtime
|
|
672
|
+
* shape is intentionally not pinned to a TypeScript type from the SDK
|
|
673
|
+
* because the SDK may evolve the field names; this extractor probes
|
|
674
|
+
* the documented Cursor surface AND falls back to a top-level
|
|
675
|
+
* `{ status, message }` shape so a slightly older or newer SDK still
|
|
676
|
+
* surfaces the signal.
|
|
677
|
+
*
|
|
678
|
+
* Returns `null` when the event is not a status message or lacks the
|
|
679
|
+
* structured payload (the common case — most stream events are
|
|
680
|
+
* `assistant` / `thinking` / `tool_call`).
|
|
681
|
+
*/
|
|
682
|
+
export function extractStatusMessage(event) {
|
|
683
|
+
if (typeof event !== "object" || event === null)
|
|
684
|
+
return null;
|
|
685
|
+
const e = event;
|
|
686
|
+
// Cursor SDK shape (per spike fixture): assistant/thinking events use
|
|
687
|
+
// `type: "assistant"` etc.; status messages use either `type: "status"`
|
|
688
|
+
// or an explicit `kind: "status_message"`. Both surfaces are observed
|
|
689
|
+
// in the wild — accept either as long as the structured payload is
|
|
690
|
+
// present.
|
|
691
|
+
const isStatusEvent = e["type"] === "status" ||
|
|
692
|
+
e["type"] === "status_message" ||
|
|
693
|
+
e["kind"] === "status_message" ||
|
|
694
|
+
e["kind"] === "status";
|
|
695
|
+
if (!isStatusEvent)
|
|
696
|
+
return null;
|
|
697
|
+
// Sometimes the payload is at the top level; sometimes it is nested
|
|
698
|
+
// under `data` or `message`. Probe in order; first hit wins.
|
|
699
|
+
const candidates = [
|
|
700
|
+
e,
|
|
701
|
+
e["data"],
|
|
702
|
+
e["message"],
|
|
703
|
+
e["payload"],
|
|
704
|
+
];
|
|
705
|
+
for (const candidate of candidates) {
|
|
706
|
+
if (typeof candidate !== "object" || candidate === null)
|
|
707
|
+
continue;
|
|
708
|
+
const c = candidate;
|
|
709
|
+
const status = c["status"];
|
|
710
|
+
const message = c["message"];
|
|
711
|
+
if (typeof status === "string" && status.length > 0 && typeof message === "string" && message.length > 0) {
|
|
712
|
+
return { status, message };
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
return null;
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Probe a terminal RunResult for the SDK's structured error code.
|
|
719
|
+
*
|
|
720
|
+
* The Cursor SDK has used at least four field names for this code
|
|
721
|
+
* across versions: `errorCode` (current), `error_code`, `code`, and
|
|
722
|
+
* `error.code` (nested under an `error` object). Probe in that order
|
|
723
|
+
* — first non-empty string wins. Returns `null` when none are
|
|
724
|
+
* present, which means the SDK did not surface a structured code at
|
|
725
|
+
* all (treat as "transient unless proven otherwise" so retry policy
|
|
726
|
+
* applies).
|
|
727
|
+
*/
|
|
728
|
+
export function extractRunErrorCode(result) {
|
|
729
|
+
if (typeof result !== "object" || result === null)
|
|
730
|
+
return null;
|
|
731
|
+
const r = result;
|
|
732
|
+
// Top-level surfaces, in priority order. `errorCode` is the
|
|
733
|
+
// documented current name; the others are legacy / underscored /
|
|
734
|
+
// nested.
|
|
735
|
+
const direct = [r["errorCode"], r["error_code"], r["code"]];
|
|
736
|
+
for (const v of direct) {
|
|
737
|
+
if (typeof v === "string" && v.length > 0)
|
|
738
|
+
return v;
|
|
739
|
+
}
|
|
740
|
+
// Nested under `error: { code }`.
|
|
741
|
+
const errorObj = r["error"];
|
|
742
|
+
if (typeof errorObj === "object" && errorObj !== null) {
|
|
743
|
+
const nested = errorObj["code"];
|
|
744
|
+
if (typeof nested === "string" && nested.length > 0)
|
|
745
|
+
return nested;
|
|
746
|
+
}
|
|
747
|
+
return null;
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Policy gate: decide whether a terminal Cursor SDK failure is
|
|
751
|
+
* retryable.
|
|
752
|
+
*
|
|
753
|
+
* Returns `false` (DO NOT retry) when:
|
|
754
|
+
* - `runId` is null/undefined — the SDK never accepted the request;
|
|
755
|
+
* this is an infrastructure failure (network, sandbox, adapter
|
|
756
|
+
* init), not an API-layer failure that retry can paper over.
|
|
757
|
+
* - `errorCode` is in {@link PERMANENT_ERROR_CODES} — retrying an
|
|
758
|
+
* auth failure or quota-exceeded just wastes budget AND can mask
|
|
759
|
+
* the real fault.
|
|
760
|
+
*
|
|
761
|
+
* Returns `true` (retry allowed) when the failure carries a `runId`
|
|
762
|
+
* AND `errorCode` is either missing OR not on the permanent-deny
|
|
763
|
+
* list. The 26/27 retryable-failure success rate documented at
|
|
764
|
+
* `RETRY_BACKOFF_MS` drives this policy without per-vendor heuristics.
|
|
765
|
+
*/
|
|
766
|
+
export function shouldRetryRunFailure(input) {
|
|
767
|
+
// Without a runId, the SDK didn't accept the request — there is
|
|
768
|
+
// nothing on the upstream side to retry. Retrying here would just
|
|
769
|
+
// re-run the same infrastructure-level failure.
|
|
770
|
+
if (!input.runId)
|
|
771
|
+
return false;
|
|
772
|
+
// Permanent-error deny list short-circuits retries.
|
|
773
|
+
if (input.errorCode && PERMANENT_ERROR_CODES.has(input.errorCode))
|
|
774
|
+
return false;
|
|
775
|
+
return true;
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* AbortSignal-aware sleep used between retry attempts.
|
|
779
|
+
*
|
|
780
|
+
* Resolves after `RETRY_BACKOFF_MS[idx]` ms, OR rejects immediately
|
|
781
|
+
* with an Error whose `name === "AbortError"` if the signal is (or
|
|
782
|
+
* becomes) aborted. The abort handler also clears the pending timer
|
|
783
|
+
* so a long backoff doesn't leak a Node timer after the caller
|
|
784
|
+
* cancelled.
|
|
785
|
+
*
|
|
786
|
+
* Throws synchronously (via the returned rejected promise) on
|
|
787
|
+
* out-of-range `idx` so an indexing bug in the caller fails loud
|
|
788
|
+
* instead of silently sleeping zero ms.
|
|
789
|
+
*/
|
|
790
|
+
export async function sleepForRetry(idx, signal) {
|
|
791
|
+
if (idx < 0 || idx >= RETRY_BACKOFF_MS.length) {
|
|
792
|
+
throw new Error(`sleepForRetry: idx ${idx} out of range (RETRY_BACKOFF_MS.length=${RETRY_BACKOFF_MS.length})`);
|
|
793
|
+
}
|
|
794
|
+
const ms = RETRY_BACKOFF_MS[idx];
|
|
795
|
+
if (signal?.aborted) {
|
|
796
|
+
const e = new Error("aborted");
|
|
797
|
+
e.name = "AbortError";
|
|
798
|
+
throw e;
|
|
799
|
+
}
|
|
800
|
+
await new Promise((resolveSleep, rejectSleep) => {
|
|
801
|
+
const timer = setTimeout(() => {
|
|
802
|
+
if (signal)
|
|
803
|
+
signal.removeEventListener("abort", onAbort);
|
|
804
|
+
resolveSleep();
|
|
805
|
+
}, ms);
|
|
806
|
+
function onAbort() {
|
|
807
|
+
clearTimeout(timer);
|
|
808
|
+
if (signal)
|
|
809
|
+
signal.removeEventListener("abort", onAbort);
|
|
810
|
+
const e = new Error("aborted");
|
|
811
|
+
e.name = "AbortError";
|
|
812
|
+
rejectSleep(e);
|
|
813
|
+
}
|
|
814
|
+
if (signal)
|
|
815
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
//# sourceMappingURL=cursor-sdk.js.map
|