sequant 2.6.2 → 2.8.0
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +13 -1
- package/dist/bin/cli.d.ts +1 -1
- package/dist/bin/cli.js +11 -1
- package/dist/bin/preflight.d.ts +21 -0
- package/dist/bin/preflight.js +45 -0
- package/dist/marketplace/external_plugins/sequant/.claude-plugin/plugin.json +1 -1
- package/dist/marketplace/external_plugins/sequant/skills/_shared/references/force-push.md +34 -0
- package/dist/marketplace/external_plugins/sequant/skills/assess/SKILL.md +24 -7
- package/dist/marketplace/external_plugins/sequant/skills/exec/SKILL.md +29 -0
- package/dist/marketplace/external_plugins/sequant/skills/loop/SKILL.md +100 -2
- package/dist/marketplace/external_plugins/sequant/skills/qa/SKILL.md +24 -0
- package/dist/marketplace/external_plugins/sequant/skills/qa/references/anti-pattern-detection.md +285 -0
- package/dist/marketplace/external_plugins/sequant/skills/qa/references/call-site-review.md +202 -0
- package/dist/marketplace/external_plugins/sequant/skills/qa/references/quality-gates.md +287 -0
- package/dist/marketplace/external_plugins/sequant/skills/qa/references/test-quality-checklist.md +272 -0
- package/dist/marketplace/external_plugins/sequant/skills/qa/references/testing-requirements.md +40 -0
- package/dist/marketplace/external_plugins/sequant/skills/qa/scripts/quality-checks.sh +95 -11
- package/dist/marketplace/external_plugins/sequant/skills/references/shared/framework-gotchas.md +186 -0
- package/dist/marketplace/external_plugins/sequant/skills/release/SKILL.md +661 -0
- package/dist/marketplace/external_plugins/sequant/skills/test/references/browser-testing-patterns.md +423 -0
- package/dist/marketplace/external_plugins/sequant/skills/upstream/SKILL.md +419 -0
- package/dist/src/commands/sync.d.ts +1 -0
- package/dist/src/commands/sync.js +56 -1
- package/dist/src/commands/update.js +7 -0
- package/dist/src/lib/errors.d.ts +85 -0
- package/dist/src/lib/errors.js +111 -0
- package/dist/src/lib/version-check.d.ts +19 -0
- package/dist/src/lib/version-check.js +44 -0
- package/dist/src/lib/workflow/batch-executor.js +61 -6
- package/dist/src/lib/workflow/drivers/agent-driver.d.ts +17 -0
- package/dist/src/lib/workflow/drivers/claude-code.d.ts +22 -0
- package/dist/src/lib/workflow/drivers/claude-code.js +111 -7
- package/dist/src/lib/workflow/log-writer.d.ts +1 -1
- package/dist/src/lib/workflow/phase-executor.d.ts +18 -0
- package/dist/src/lib/workflow/phase-executor.js +76 -14
- package/dist/src/lib/workflow/run-log-schema.d.ts +3 -0
- package/dist/src/lib/workflow/run-log-schema.js +7 -0
- package/dist/src/lib/workflow/state-manager.d.ts +1 -0
- package/dist/src/lib/workflow/state-manager.js +6 -0
- package/dist/src/lib/workflow/state-schema.d.ts +3 -0
- package/dist/src/lib/workflow/state-schema.js +7 -0
- package/dist/src/lib/workflow/types.d.ts +17 -0
- package/dist/src/ui/tui/theme.d.ts +18 -4
- package/dist/src/ui/tui/theme.js +18 -4
- package/package.json +4 -3
- package/templates/skills/_shared/references/force-push.md +34 -0
- package/templates/skills/assess/SKILL.md +24 -7
- package/templates/skills/exec/SKILL.md +29 -0
- package/templates/skills/loop/SKILL.md +100 -2
- package/templates/skills/qa/SKILL.md +24 -0
- package/templates/skills/qa/references/anti-pattern-detection.md +285 -0
- package/templates/skills/qa/references/call-site-review.md +202 -0
- package/templates/skills/qa/references/quality-gates.md +287 -0
- package/templates/skills/qa/references/test-quality-checklist.md +272 -0
- package/templates/skills/qa/references/testing-requirements.md +40 -0
- package/templates/skills/qa/scripts/quality-checks.sh +95 -11
- package/templates/skills/references/shared/framework-gotchas.md +186 -0
- package/templates/skills/release/SKILL.md +661 -0
- package/templates/skills/test/references/browser-testing-patterns.md +423 -0
- package/templates/skills/upstream/SKILL.md +419 -0
package/dist/src/lib/errors.js
CHANGED
|
@@ -82,6 +82,115 @@ export class SubprocessError extends SequantError {
|
|
|
82
82
|
this.name = "SubprocessError";
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Transient rate-limit error (HTTP 429-style throttle, overloaded API).
|
|
87
|
+
*
|
|
88
|
+
* Retryable: waiting and re-running can succeed once the limit window resets.
|
|
89
|
+
*/
|
|
90
|
+
export class RateLimitError extends SequantError {
|
|
91
|
+
constructor(message, metadata = {}, cause) {
|
|
92
|
+
super(message, { isRetryable: true, metadata, cause });
|
|
93
|
+
this.name = "RateLimitError";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Billing / out-of-credits error.
|
|
98
|
+
*
|
|
99
|
+
* NOT retryable: a no-MCP retry (or any retry) cannot refill credits, so the
|
|
100
|
+
* executor must surface the real cause instead of looping. Drives the #592
|
|
101
|
+
* fallback-noise skip in phase-executor.
|
|
102
|
+
*/
|
|
103
|
+
export class BillingError extends SequantError {
|
|
104
|
+
constructor(message, metadata = {}, cause) {
|
|
105
|
+
super(message, { isRetryable: false, metadata, cause });
|
|
106
|
+
this.name = "BillingError";
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* True when the rate-limit info represents a billing/credits failure (which a
|
|
111
|
+
* retry cannot fix), rather than a transient throttle.
|
|
112
|
+
*/
|
|
113
|
+
export function isBillingFailure(info) {
|
|
114
|
+
return (info.errorCode === "credits_required" ||
|
|
115
|
+
info.overageDisabledReason === "out_of_credits");
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* True when the rate-limit info represents an actual failure (rejection or
|
|
119
|
+
* billing), as opposed to an informational `allowed` / `allowed_warning`
|
|
120
|
+
* event. The driver uses this to avoid mis-attributing a stale warning event
|
|
121
|
+
* to an unrelated phase failure.
|
|
122
|
+
*/
|
|
123
|
+
export function isRateLimitFailureInfo(info) {
|
|
124
|
+
return info.status === "rejected" || isBillingFailure(info);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Format a Unix timestamp (seconds or ms) as a local time string.
|
|
128
|
+
*
|
|
129
|
+
* Bare `HH:MM` when the reset falls on the current local calendar day;
|
|
130
|
+
* date-qualified `MM-DD HH:MM` otherwise. Multi-day windows
|
|
131
|
+
* (`rateLimitType: seven_day*`) can reset days out — a bare `HH:MM` there reads
|
|
132
|
+
* as "later today" and misleads the user (#732 QA follow-up), so the date is
|
|
133
|
+
* included whenever the reset is not today.
|
|
134
|
+
*/
|
|
135
|
+
function formatResetTime(resetsAt) {
|
|
136
|
+
// Heuristic: values below ~1e12 are seconds, otherwise milliseconds.
|
|
137
|
+
const ms = resetsAt < 1e12 ? resetsAt * 1000 : resetsAt;
|
|
138
|
+
const d = new Date(ms);
|
|
139
|
+
const hh = String(d.getHours()).padStart(2, "0");
|
|
140
|
+
const mm = String(d.getMinutes()).padStart(2, "0");
|
|
141
|
+
const now = new Date();
|
|
142
|
+
const sameDay = d.getFullYear() === now.getFullYear() &&
|
|
143
|
+
d.getMonth() === now.getMonth() &&
|
|
144
|
+
d.getDate() === now.getDate();
|
|
145
|
+
if (sameDay) {
|
|
146
|
+
return `${hh}:${mm}`;
|
|
147
|
+
}
|
|
148
|
+
const mon = String(d.getMonth() + 1).padStart(2, "0");
|
|
149
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
150
|
+
return `${mon}-${day} ${hh}:${mm}`;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Build a user-facing message from rate-limit info, naming the real cause:
|
|
154
|
+
* - billing/credits → "Out of credits" (enriched with purchasable vs hard
|
|
155
|
+
* limit when the ≥0.3.181 `canUserPurchaseCredits` field is present)
|
|
156
|
+
* - transient throttle → "Rate limited — resets at HH:MM" (date-qualified as
|
|
157
|
+
* "MM-DD HH:MM" when the reset is not today; reset time omitted entirely when
|
|
158
|
+
* `resetsAt` is absent)
|
|
159
|
+
*/
|
|
160
|
+
export function formatRateLimitMessage(info) {
|
|
161
|
+
if (isBillingFailure(info)) {
|
|
162
|
+
if (info.canUserPurchaseCredits === true) {
|
|
163
|
+
return "Out of credits — purchasable";
|
|
164
|
+
}
|
|
165
|
+
if (info.canUserPurchaseCredits === false) {
|
|
166
|
+
return "Out of credits — hard limit";
|
|
167
|
+
}
|
|
168
|
+
return "Out of credits";
|
|
169
|
+
}
|
|
170
|
+
if (info.resetsAt !== undefined) {
|
|
171
|
+
return `Rate limited — resets at ${formatResetTime(info.resetsAt)}`;
|
|
172
|
+
}
|
|
173
|
+
return "Rate limited";
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Construct the appropriate typed error from structured rate-limit info.
|
|
177
|
+
* Billing/credits failures become a non-retryable {@link BillingError};
|
|
178
|
+
* transient throttles become a retryable {@link RateLimitError}.
|
|
179
|
+
*/
|
|
180
|
+
export function createRateLimitError(info) {
|
|
181
|
+
const message = formatRateLimitMessage(info);
|
|
182
|
+
const metadata = {
|
|
183
|
+
resetsAt: info.resetsAt,
|
|
184
|
+
rateLimitType: info.rateLimitType,
|
|
185
|
+
overageDisabledReason: info.overageDisabledReason,
|
|
186
|
+
errorCode: info.errorCode,
|
|
187
|
+
canUserPurchaseCredits: info.canUserPurchaseCredits,
|
|
188
|
+
hasChargeableSavedPaymentMethod: info.hasChargeableSavedPaymentMethod,
|
|
189
|
+
};
|
|
190
|
+
return isBillingFailure(info)
|
|
191
|
+
? new BillingError(message, metadata)
|
|
192
|
+
: new RateLimitError(message, metadata);
|
|
193
|
+
}
|
|
85
194
|
/**
|
|
86
195
|
* Map of error type names to their constructors.
|
|
87
196
|
* Used for deserialization from logs.
|
|
@@ -94,4 +203,6 @@ export const ERROR_TYPE_MAP = {
|
|
|
94
203
|
BuildError: BuildError,
|
|
95
204
|
TimeoutError: TimeoutError,
|
|
96
205
|
SubprocessError: SubprocessError,
|
|
206
|
+
RateLimitError: RateLimitError,
|
|
207
|
+
BillingError: BillingError,
|
|
97
208
|
};
|
|
@@ -114,6 +114,25 @@ export declare function fetchLatestVersion(): Promise<string | null>;
|
|
|
114
114
|
* Returns: -1 if a < b, 0 if a == b, 1 if a > b
|
|
115
115
|
*/
|
|
116
116
|
export declare function compareVersions(a: string, b: string): number;
|
|
117
|
+
/**
|
|
118
|
+
* Pure preflight check for the running Node version against the engines floor.
|
|
119
|
+
*
|
|
120
|
+
* Returns an actionable, multi-line message when `current` is below `floor`,
|
|
121
|
+
* or `null` when it satisfies the floor (or when `floor` is missing/unparseable,
|
|
122
|
+
* in which case the guard is skipped rather than crashing the CLI).
|
|
123
|
+
*
|
|
124
|
+
* `floor` is the raw `engines.node` value (e.g. ">=22.12.0"); the leading range
|
|
125
|
+
* operator is stripped before comparison. Reuses {@link compareVersions} — no
|
|
126
|
+
* `semver` dependency.
|
|
127
|
+
*/
|
|
128
|
+
export declare function getNodeVersionError(current: string, floor: string | null | undefined): string | null;
|
|
129
|
+
/**
|
|
130
|
+
* Side-effecting wrapper around {@link getNodeVersionError}: prints the message
|
|
131
|
+
* and exits non-zero when the running Node is below the floor. Uses only
|
|
132
|
+
* built-in globals (`process.version`, `console`, `process.exit`) so it runs —
|
|
133
|
+
* rather than crashes — on the old Node it rejects.
|
|
134
|
+
*/
|
|
135
|
+
export declare function assertNodeVersion(floor: string | null | undefined): void;
|
|
117
136
|
/**
|
|
118
137
|
* Check if the current version is outdated
|
|
119
138
|
*/
|
|
@@ -258,6 +258,50 @@ export function compareVersions(a, b) {
|
|
|
258
258
|
}
|
|
259
259
|
return 0;
|
|
260
260
|
}
|
|
261
|
+
/**
|
|
262
|
+
* Pure preflight check for the running Node version against the engines floor.
|
|
263
|
+
*
|
|
264
|
+
* Returns an actionable, multi-line message when `current` is below `floor`,
|
|
265
|
+
* or `null` when it satisfies the floor (or when `floor` is missing/unparseable,
|
|
266
|
+
* in which case the guard is skipped rather than crashing the CLI).
|
|
267
|
+
*
|
|
268
|
+
* `floor` is the raw `engines.node` value (e.g. ">=22.12.0"); the leading range
|
|
269
|
+
* operator is stripped before comparison. Reuses {@link compareVersions} — no
|
|
270
|
+
* `semver` dependency.
|
|
271
|
+
*/
|
|
272
|
+
export function getNodeVersionError(current, floor) {
|
|
273
|
+
// Strip any range operator (">=", "^", "~", etc.) from the floor.
|
|
274
|
+
const normalizedFloor = (floor ?? "").replace(/^[^\d]*/, "");
|
|
275
|
+
// No usable floor → skip the guard (metadata problem must not crash the CLI).
|
|
276
|
+
if (!/^\d/.test(normalizedFloor)) {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
if (compareVersions(current, normalizedFloor) >= 0) {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
const currentClean = current.replace(/^v/, "");
|
|
283
|
+
return [
|
|
284
|
+
`Sequant requires Node.js >=${normalizedFloor}, but you are running ${currentClean}.`,
|
|
285
|
+
"",
|
|
286
|
+
"Upgrade Node, then re-run:",
|
|
287
|
+
" • fnm: fnm install 22 && fnm use 22",
|
|
288
|
+
" • nvm: nvm install 22 && nvm use 22",
|
|
289
|
+
" • or download: https://nodejs.org/en/download",
|
|
290
|
+
].join("\n");
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Side-effecting wrapper around {@link getNodeVersionError}: prints the message
|
|
294
|
+
* and exits non-zero when the running Node is below the floor. Uses only
|
|
295
|
+
* built-in globals (`process.version`, `console`, `process.exit`) so it runs —
|
|
296
|
+
* rather than crashes — on the old Node it rejects.
|
|
297
|
+
*/
|
|
298
|
+
export function assertNodeVersion(floor) {
|
|
299
|
+
const error = getNodeVersionError(process.version, floor);
|
|
300
|
+
if (error) {
|
|
301
|
+
console.error(error);
|
|
302
|
+
process.exit(1);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
261
305
|
/**
|
|
262
306
|
* Check if the current version is outdated
|
|
263
307
|
*/
|
|
@@ -432,7 +432,17 @@ export async function runIssueWithLogging(ctx) {
|
|
|
432
432
|
}
|
|
433
433
|
}
|
|
434
434
|
else {
|
|
435
|
-
|
|
435
|
+
// Mirror the main phase loop (#739): a turn-capped spec phase surfaces the
|
|
436
|
+
// distinct "partial output preserved" signal rather than a generic failure
|
|
437
|
+
// reason, so the cap is recognizable on the spec path too (it has its own
|
|
438
|
+
// failure handling, separate from the main loop). The partial output is
|
|
439
|
+
// preserved in `phaseResults` (pushed above) and the run still halts via
|
|
440
|
+
// the early return below.
|
|
441
|
+
const extra = {
|
|
442
|
+
error: specResult.capped
|
|
443
|
+
? "turn cap reached — partial output preserved (resume to continue)"
|
|
444
|
+
: (specResult.error ?? "unknown"),
|
|
445
|
+
};
|
|
436
446
|
emitProgressLine(issueNumber, "spec", "failed", extra);
|
|
437
447
|
try {
|
|
438
448
|
onProgress?.(issueNumber, "spec", "failed", extra);
|
|
@@ -462,7 +472,13 @@ export async function runIssueWithLogging(ctx) {
|
|
|
462
472
|
? "success"
|
|
463
473
|
: specResult.error?.includes("Timeout")
|
|
464
474
|
? "timeout"
|
|
465
|
-
: "failure", {
|
|
475
|
+
: "failure", {
|
|
476
|
+
error: specResult.error,
|
|
477
|
+
// Mark a turn-capped spec phase distinctly in the log (#739), matching
|
|
478
|
+
// the main phase loop: status stays "failure" but `capped` flags it.
|
|
479
|
+
capped: specResult.capped,
|
|
480
|
+
errorContext: specErrorContext,
|
|
481
|
+
});
|
|
466
482
|
logWriter.logPhase(phaseLog);
|
|
467
483
|
}
|
|
468
484
|
// Track spec phase completion in state
|
|
@@ -471,6 +487,9 @@ export async function runIssueWithLogging(ctx) {
|
|
|
471
487
|
const phaseStatus = specResult.success ? "completed" : "failed";
|
|
472
488
|
await stateManager.updatePhaseStatus(issueNumber, "spec", phaseStatus, {
|
|
473
489
|
error: specResult.error,
|
|
490
|
+
// Mark a turn-capped spec halt distinctly in state (#739), matching
|
|
491
|
+
// the run-log marker — status stays "failed", `capped` flags it.
|
|
492
|
+
capped: specResult.capped,
|
|
474
493
|
});
|
|
475
494
|
}
|
|
476
495
|
catch {
|
|
@@ -595,6 +614,10 @@ export async function runIssueWithLogging(ctx) {
|
|
|
595
614
|
const useQualityLoop = config.qualityLoop || detectedQualityLoop;
|
|
596
615
|
const maxIterations = useQualityLoop ? config.maxIterations : 1;
|
|
597
616
|
let completedSuccessfully = false;
|
|
617
|
+
// Set when a phase hits its turn cap (#739): halt the outer quality-loop
|
|
618
|
+
// retry too, not just the inner /loop spawn — re-running a capped phase
|
|
619
|
+
// would only cap again, and "surface + halt" means the user resumes.
|
|
620
|
+
let haltedByCap = false;
|
|
598
621
|
while (iteration < maxIterations) {
|
|
599
622
|
iteration++;
|
|
600
623
|
if (useQualityLoop && iteration > 1) {
|
|
@@ -655,7 +678,18 @@ export async function runIssueWithLogging(ctx) {
|
|
|
655
678
|
}
|
|
656
679
|
}
|
|
657
680
|
else {
|
|
658
|
-
|
|
681
|
+
// A turn-capped phase is incomplete-but-not-hard-failed (#739): surface a
|
|
682
|
+
// distinct "partial output preserved" signal instead of a generic failure
|
|
683
|
+
// reason, so the user knows the run halted on a recoverable cap (and can
|
|
684
|
+
// resume) rather than on a genuine error. The partial `result.output` is
|
|
685
|
+
// already preserved in `phaseResults` (pushed above) and the phase log
|
|
686
|
+
// (`capped` flag below); the run still halts cleanly at the `break` below.
|
|
687
|
+
const extra = {
|
|
688
|
+
error: result.capped
|
|
689
|
+
? "turn cap reached — partial output preserved (resume to continue)"
|
|
690
|
+
: (result.error ?? "unknown"),
|
|
691
|
+
iteration,
|
|
692
|
+
};
|
|
659
693
|
emitProgressLine(issueNumber, phase, "failed", extra);
|
|
660
694
|
try {
|
|
661
695
|
onProgress?.(issueNumber, phase, "failed", extra);
|
|
@@ -696,6 +730,9 @@ export async function runIssueWithLogging(ctx) {
|
|
|
696
730
|
? "timeout"
|
|
697
731
|
: "failure", {
|
|
698
732
|
error: result.error,
|
|
733
|
+
// Mark a turn-capped phase distinctly in the log (#739): status stays
|
|
734
|
+
// "failure" (no new enum value) but `capped` flags it as recoverable.
|
|
735
|
+
capped: result.capped,
|
|
699
736
|
verdict: result.verdict,
|
|
700
737
|
summary: result.summary,
|
|
701
738
|
// Observability fields (AC-1, AC-2, AC-3, AC-7)
|
|
@@ -715,7 +752,13 @@ export async function runIssueWithLogging(ctx) {
|
|
|
715
752
|
: result.error?.includes("Timeout")
|
|
716
753
|
? "failed"
|
|
717
754
|
: "failed";
|
|
718
|
-
await stateManager.updatePhaseStatus(issueNumber, phase, phaseStatus, {
|
|
755
|
+
await stateManager.updatePhaseStatus(issueNumber, phase, phaseStatus, {
|
|
756
|
+
error: result.error,
|
|
757
|
+
// Mark a turn-capped phase halt distinctly in state (#739),
|
|
758
|
+
// matching the run-log marker — status stays "failed",
|
|
759
|
+
// `capped` flags it as recoverable for the resume path.
|
|
760
|
+
capped: result.capped,
|
|
761
|
+
});
|
|
719
762
|
}
|
|
720
763
|
catch {
|
|
721
764
|
// State tracking errors shouldn't stop execution
|
|
@@ -726,8 +769,15 @@ export async function runIssueWithLogging(ctx) {
|
|
|
726
769
|
}
|
|
727
770
|
else {
|
|
728
771
|
phasesFailed = true;
|
|
729
|
-
|
|
730
|
-
|
|
772
|
+
if (result.capped) {
|
|
773
|
+
haltedByCap = true;
|
|
774
|
+
}
|
|
775
|
+
// If quality loop enabled, run loop phase to fix issues.
|
|
776
|
+
// A turn-capped phase (#739) is incomplete, not a genuine quality
|
|
777
|
+
// failure: skip the loop and halt cleanly ("surface + halt"). Spawning
|
|
778
|
+
// /loop on partial output would act on incomplete work — exactly the
|
|
779
|
+
// risk the capped path is meant to avoid. The user resumes instead.
|
|
780
|
+
if (useQualityLoop && iteration < maxIterations && !result.capped) {
|
|
731
781
|
// #624 Item 3 (AC-3.3): the loop phase carries the current outer
|
|
732
782
|
// iteration so the live-zone status cell can show `loop N/M`.
|
|
733
783
|
const loopStartExtra = { iteration };
|
|
@@ -790,6 +840,11 @@ export async function runIssueWithLogging(ctx) {
|
|
|
790
840
|
completedSuccessfully = true;
|
|
791
841
|
break;
|
|
792
842
|
}
|
|
843
|
+
// A turn-capped phase (#739) halts the outer quality-loop retry as well —
|
|
844
|
+
// re-running would only cap again; the partial work is already preserved.
|
|
845
|
+
if (haltedByCap) {
|
|
846
|
+
break;
|
|
847
|
+
}
|
|
793
848
|
// If we're not in quality loop mode, don't retry
|
|
794
849
|
if (!config.qualityLoop) {
|
|
795
850
|
break;
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Continue.dev, Copilot SDK, Cursor API) can be added by implementing this
|
|
6
6
|
* interface without touching orchestration logic.
|
|
7
7
|
*/
|
|
8
|
+
import type { SequantError } from "../../errors.js";
|
|
8
9
|
/**
|
|
9
10
|
* Resume handle for a previous agent session.
|
|
10
11
|
*
|
|
@@ -64,6 +65,22 @@ export interface AgentPhaseResult {
|
|
|
64
65
|
/** Driver-tagged resume handle for cwd-safe cross-phase resume (#674). */
|
|
65
66
|
resumeHandle?: ResumeHandle;
|
|
66
67
|
error?: string;
|
|
68
|
+
/**
|
|
69
|
+
* Set when the agent hit its `maxTurns` ceiling (`error_max_turns`). The
|
|
70
|
+
* `output` is partial-but-usable rather than a hard failure, so consumers
|
|
71
|
+
* can treat it as inconclusive/incomplete instead of discarding the work.
|
|
72
|
+
* See #733.
|
|
73
|
+
*/
|
|
74
|
+
capped?: boolean;
|
|
75
|
+
/**
|
|
76
|
+
* Typed error carrying structured cause data (#732). Set by drivers that can
|
|
77
|
+
* observe structured failure signals (e.g. ClaudeCodeDriver reading the SDK's
|
|
78
|
+
* `rate_limit_event` / assistant `error`). The executor prefers this over
|
|
79
|
+
* stderr-regex classification and uses its type to gate retry behavior (e.g.
|
|
80
|
+
* skipping the MCP fallback for non-retryable billing failures). Left
|
|
81
|
+
* undefined by drivers without structured signals (aider, subprocess paths).
|
|
82
|
+
*/
|
|
83
|
+
structuredError?: SequantError;
|
|
67
84
|
/** Last N lines of stderr captured via RingBuffer (#447) */
|
|
68
85
|
stderrTail?: string[];
|
|
69
86
|
/** Last N lines of stdout captured via RingBuffer (#447) */
|
|
@@ -28,6 +28,28 @@ export declare class ClaudeCodeDriver implements AgentDriver {
|
|
|
28
28
|
*/
|
|
29
29
|
canResume(handle: ResumeHandle, targetCwd: string): boolean;
|
|
30
30
|
executePhase(prompt: string, config: AgentExecutionConfig): Promise<AgentPhaseResult>;
|
|
31
|
+
/**
|
|
32
|
+
* Derive a typed {@link SequantError} from structured SDK failure signals
|
|
33
|
+
* (#732). Precedence: a captured `rate_limit_event` (richest signal) wins;
|
|
34
|
+
* otherwise the assistant-level `error`; otherwise the last `api_retry`
|
|
35
|
+
* error. Returns undefined when no rate-limit/billing signal was seen, so
|
|
36
|
+
* the executor falls back to stderr-regex classification.
|
|
37
|
+
*
|
|
38
|
+
* Exception: a non-retryable billing failure must never be downgraded to a
|
|
39
|
+
* retryable {@link RateLimitError}. If the `rate_limit_event` was only a
|
|
40
|
+
* transient throttle but the assistant separately reported `billing_error`,
|
|
41
|
+
* the billing cause wins — a retry cannot refill credits, and a
|
|
42
|
+
* RateLimitError would wrongly re-enable the retry / MCP-fallback path. When
|
|
43
|
+
* the `rate_limit_event` is itself a billing failure its richer metadata
|
|
44
|
+
* (`canUserPurchaseCredits`, etc.) is preserved.
|
|
45
|
+
*/
|
|
46
|
+
private buildStructuredError;
|
|
47
|
+
/**
|
|
48
|
+
* Map the SDK's assistant/api-retry error enum to a typed error. Only
|
|
49
|
+
* rate-limit / billing variants are mapped; other variants (auth, etc.)
|
|
50
|
+
* return undefined and defer to the existing classification path.
|
|
51
|
+
*/
|
|
52
|
+
private errorFromAssistantError;
|
|
31
53
|
private buildResumeHandle;
|
|
32
54
|
isAvailable(): Promise<boolean>;
|
|
33
55
|
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
8
8
|
import { getMcpServersConfig } from "../../system.js";
|
|
9
|
+
import { RateLimitError, BillingError, createRateLimitError, isRateLimitFailureInfo, } from "../../errors.js";
|
|
9
10
|
import { RingBuffer } from "../ring-buffer.js";
|
|
10
11
|
export class ClaudeCodeDriver {
|
|
11
12
|
name = "claude-code";
|
|
@@ -47,6 +48,16 @@ export class ClaudeCodeDriver {
|
|
|
47
48
|
let resultMessage;
|
|
48
49
|
let capturedOutput = "";
|
|
49
50
|
let capturedStderr = "";
|
|
51
|
+
// Structured rate-limit / billing signals captured from the SDK stream
|
|
52
|
+
// (#732). The SDK emits these but sequant previously dropped them on the
|
|
53
|
+
// floor, falling back to regex-on-stderr classification. We keep only the
|
|
54
|
+
// latest *failure-grade* rate-limit info (rejection or billing) so an
|
|
55
|
+
// informational `allowed_warning` event isn't mis-attributed to an
|
|
56
|
+
// unrelated phase failure.
|
|
57
|
+
let rateLimitInfo;
|
|
58
|
+
let assistantError;
|
|
59
|
+
// Last api_retry signal, captured opportunistically for diagnostics.
|
|
60
|
+
let apiRetryError;
|
|
50
61
|
const stderrBuffer = new RingBuffer(50);
|
|
51
62
|
const stdoutBuffer = new RingBuffer(50);
|
|
52
63
|
// Resolve resume token with cwd-safety check.
|
|
@@ -99,7 +110,26 @@ export class ClaudeCodeDriver {
|
|
|
99
110
|
if (message.type === "system" && message.subtype === "init") {
|
|
100
111
|
resultSessionId = message.session_id;
|
|
101
112
|
}
|
|
113
|
+
// Capture structured rate-limit info (#732). Only retain
|
|
114
|
+
// failure-grade events (rejection / billing) so a benign warning
|
|
115
|
+
// doesn't poison the failure path.
|
|
116
|
+
if (message.type === "rate_limit_event" &&
|
|
117
|
+
isRateLimitFailureInfo(message.rate_limit_info)) {
|
|
118
|
+
rateLimitInfo = message.rate_limit_info;
|
|
119
|
+
}
|
|
120
|
+
// Capture api_retry diagnostics (#732, optional). These are transient
|
|
121
|
+
// retries the SDK performs internally; recorded for the structured
|
|
122
|
+
// error fallback when no rate_limit_event/assistant error is present.
|
|
123
|
+
if (message.type === "system" && message.subtype === "api_retry") {
|
|
124
|
+
apiRetryError = message.error;
|
|
125
|
+
}
|
|
102
126
|
if (message.type === "assistant") {
|
|
127
|
+
// Capture the assistant-level error field (#732) — `rate_limit`,
|
|
128
|
+
// `billing_error`, `overloaded`, etc. Previously discarded by the
|
|
129
|
+
// text-only content filter below.
|
|
130
|
+
if (message.error) {
|
|
131
|
+
assistantError = message.error;
|
|
132
|
+
}
|
|
103
133
|
const content = message.message.content;
|
|
104
134
|
const textContent = content
|
|
105
135
|
.filter((c) => c.type === "text" && c.text)
|
|
@@ -124,6 +154,10 @@ export class ClaudeCodeDriver {
|
|
|
124
154
|
// `config.cwd`. `sessionId` is mirrored for one release (#674) so
|
|
125
155
|
// upgraded callers can still drive resume off the deprecated field.
|
|
126
156
|
const resumeHandle = this.buildResumeHandle(resultSessionId, config.cwd);
|
|
157
|
+
// Build a typed error from structured SDK signals (#732). Present only
|
|
158
|
+
// when the stream surfaced a rate-limit/billing failure; otherwise
|
|
159
|
+
// undefined and the executor falls back to stderr-regex classification.
|
|
160
|
+
const structuredError = this.buildStructuredError(rateLimitInfo, assistantError, apiRetryError);
|
|
127
161
|
if (resultMessage) {
|
|
128
162
|
if (resultMessage.subtype === "success") {
|
|
129
163
|
return {
|
|
@@ -135,13 +169,29 @@ export class ClaudeCodeDriver {
|
|
|
135
169
|
stdoutTail: stdoutBuffer.getLines(),
|
|
136
170
|
};
|
|
137
171
|
}
|
|
172
|
+
// Turn-cap is a soft, recoverable outcome, not a hard failure: the
|
|
173
|
+
// agent produced partial work before hitting its `maxTurns` ceiling
|
|
174
|
+
// (turn caps are live on every agent since #484). Warn (not error)
|
|
175
|
+
// and return the partial output flagged `capped` so consumers — the
|
|
176
|
+
// /qa and /exec skills — can treat it as inconclusive/incomplete
|
|
177
|
+
// rather than discarding the work. See #733. Branched out of the
|
|
178
|
+
// error switch below so it never carries a hard `error` string.
|
|
179
|
+
if (resultMessage.subtype === "error_max_turns") {
|
|
180
|
+
config.onStderr?.("⚠️ Agent hit its turn cap (error_max_turns). Returning partial results.\n");
|
|
181
|
+
return {
|
|
182
|
+
success: false,
|
|
183
|
+
capped: true,
|
|
184
|
+
output: capturedOutput,
|
|
185
|
+
sessionId: resultSessionId,
|
|
186
|
+
resumeHandle,
|
|
187
|
+
stderrTail: stderrBuffer.getLines(),
|
|
188
|
+
stdoutTail: stdoutBuffer.getLines(),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
138
191
|
// Handle error subtypes
|
|
139
192
|
let error;
|
|
140
193
|
const errorSubtype = resultMessage.subtype;
|
|
141
|
-
if (errorSubtype === "
|
|
142
|
-
error = "Max turns reached";
|
|
143
|
-
}
|
|
144
|
-
else if (errorSubtype === "error_during_execution") {
|
|
194
|
+
if (errorSubtype === "error_during_execution") {
|
|
145
195
|
error = resultMessage.errors?.join(", ") || "Error during execution";
|
|
146
196
|
}
|
|
147
197
|
else if (errorSubtype === "error_max_budget_usd") {
|
|
@@ -155,7 +205,10 @@ export class ClaudeCodeDriver {
|
|
|
155
205
|
output: capturedOutput,
|
|
156
206
|
sessionId: resultSessionId,
|
|
157
207
|
resumeHandle,
|
|
158
|
-
|
|
208
|
+
// Prefer the structured cause (e.g. "Out of credits") over the
|
|
209
|
+
// generic subtype text when available (#732).
|
|
210
|
+
error: structuredError?.message ?? error,
|
|
211
|
+
structuredError,
|
|
159
212
|
stderrTail: stderrBuffer.getLines(),
|
|
160
213
|
stdoutTail: stdoutBuffer.getLines(),
|
|
161
214
|
};
|
|
@@ -165,7 +218,8 @@ export class ClaudeCodeDriver {
|
|
|
165
218
|
output: capturedOutput,
|
|
166
219
|
sessionId: resultSessionId,
|
|
167
220
|
resumeHandle,
|
|
168
|
-
error: "No result received from Claude",
|
|
221
|
+
error: structuredError?.message ?? "No result received from Claude",
|
|
222
|
+
structuredError,
|
|
169
223
|
stderrTail: stderrBuffer.getLines(),
|
|
170
224
|
stdoutTail: stdoutBuffer.getLines(),
|
|
171
225
|
};
|
|
@@ -182,6 +236,12 @@ export class ClaudeCodeDriver {
|
|
|
182
236
|
stdoutTail: stdoutBuffer.getLines(),
|
|
183
237
|
};
|
|
184
238
|
}
|
|
239
|
+
// If the stream surfaced a failure-grade rate-limit/billing signal before
|
|
240
|
+
// throwing, prefer that typed cause (#732) over the raw thrown message — a
|
|
241
|
+
// mid-stream throw after a *rejected* rate_limit_event is very likely the
|
|
242
|
+
// proximate cause. Abort/timeout is handled above first, so a genuine
|
|
243
|
+
// timeout is never masked by a stale rate-limit signal.
|
|
244
|
+
const structuredError = this.buildStructuredError(rateLimitInfo, assistantError, apiRetryError);
|
|
185
245
|
const stderrSuffix = capturedStderr
|
|
186
246
|
? `\nStderr: ${capturedStderr.slice(0, 500)}`
|
|
187
247
|
: "";
|
|
@@ -190,12 +250,56 @@ export class ClaudeCodeDriver {
|
|
|
190
250
|
output: capturedOutput,
|
|
191
251
|
sessionId: resultSessionId,
|
|
192
252
|
resumeHandle: this.buildResumeHandle(resultSessionId, config.cwd),
|
|
193
|
-
error: error + stderrSuffix,
|
|
253
|
+
error: structuredError?.message ?? error + stderrSuffix,
|
|
254
|
+
structuredError,
|
|
194
255
|
stderrTail: stderrBuffer.getLines(),
|
|
195
256
|
stdoutTail: stdoutBuffer.getLines(),
|
|
196
257
|
};
|
|
197
258
|
}
|
|
198
259
|
}
|
|
260
|
+
/**
|
|
261
|
+
* Derive a typed {@link SequantError} from structured SDK failure signals
|
|
262
|
+
* (#732). Precedence: a captured `rate_limit_event` (richest signal) wins;
|
|
263
|
+
* otherwise the assistant-level `error`; otherwise the last `api_retry`
|
|
264
|
+
* error. Returns undefined when no rate-limit/billing signal was seen, so
|
|
265
|
+
* the executor falls back to stderr-regex classification.
|
|
266
|
+
*
|
|
267
|
+
* Exception: a non-retryable billing failure must never be downgraded to a
|
|
268
|
+
* retryable {@link RateLimitError}. If the `rate_limit_event` was only a
|
|
269
|
+
* transient throttle but the assistant separately reported `billing_error`,
|
|
270
|
+
* the billing cause wins — a retry cannot refill credits, and a
|
|
271
|
+
* RateLimitError would wrongly re-enable the retry / MCP-fallback path. When
|
|
272
|
+
* the `rate_limit_event` is itself a billing failure its richer metadata
|
|
273
|
+
* (`canUserPurchaseCredits`, etc.) is preserved.
|
|
274
|
+
*/
|
|
275
|
+
buildStructuredError(rateLimitInfo, assistantError, apiRetryError) {
|
|
276
|
+
if (rateLimitInfo) {
|
|
277
|
+
const err = createRateLimitError(rateLimitInfo);
|
|
278
|
+
if (err instanceof RateLimitError && assistantError === "billing_error") {
|
|
279
|
+
return new BillingError("Billing error");
|
|
280
|
+
}
|
|
281
|
+
return err;
|
|
282
|
+
}
|
|
283
|
+
return (this.errorFromAssistantError(assistantError) ??
|
|
284
|
+
this.errorFromAssistantError(apiRetryError));
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Map the SDK's assistant/api-retry error enum to a typed error. Only
|
|
288
|
+
* rate-limit / billing variants are mapped; other variants (auth, etc.)
|
|
289
|
+
* return undefined and defer to the existing classification path.
|
|
290
|
+
*/
|
|
291
|
+
errorFromAssistantError(error) {
|
|
292
|
+
switch (error) {
|
|
293
|
+
case "billing_error":
|
|
294
|
+
return new BillingError("Billing error");
|
|
295
|
+
case "rate_limit":
|
|
296
|
+
return new RateLimitError("Rate limited");
|
|
297
|
+
case "overloaded":
|
|
298
|
+
return new RateLimitError("API overloaded");
|
|
299
|
+
default:
|
|
300
|
+
return undefined;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
199
303
|
buildResumeHandle(token, originCwd) {
|
|
200
304
|
if (!token)
|
|
201
305
|
return undefined;
|
|
@@ -99,4 +99,4 @@ export declare class LogWriter {
|
|
|
99
99
|
*
|
|
100
100
|
* Utility function for creating phase logs when you have start/end times.
|
|
101
101
|
*/
|
|
102
|
-
export declare function createPhaseLogFromTiming(phase: Phase, issueNumber: number, startTime: Date, endTime: Date, status: PhaseLog["status"], options?: Partial<Pick<PhaseLog, "error" | "iterations" | "filesModified" | "testsRun" | "testsPassed" | "verdict" | "summary" | "commitHash" | "fileDiffStats" | "cacheMetrics" | "errorContext">>): PhaseLog;
|
|
102
|
+
export declare function createPhaseLogFromTiming(phase: Phase, issueNumber: number, startTime: Date, endTime: Date, status: PhaseLog["status"], options?: Partial<Pick<PhaseLog, "error" | "capped" | "iterations" | "filesModified" | "testsRun" | "testsPassed" | "verdict" | "summary" | "commitHash" | "fileDiffStats" | "cacheMetrics" | "errorContext">>): PhaseLog;
|
|
@@ -111,6 +111,24 @@ export declare function mapAgentSuccessToPhaseResult(phase: Phase, agentResult:
|
|
|
111
111
|
sessionId?: string;
|
|
112
112
|
resumeHandle?: ResumeHandle;
|
|
113
113
|
};
|
|
114
|
+
/**
|
|
115
|
+
* Map a failed driver result to a `PhaseResult`.
|
|
116
|
+
*
|
|
117
|
+
* Symmetric to {@link mapAgentSuccessToPhaseResult}; extracted so the
|
|
118
|
+
* failure-path mapping (notably the #739 capped/output gating) is unit-testable
|
|
119
|
+
* without spawning a driver.
|
|
120
|
+
*
|
|
121
|
+
* `output` is propagated **only** for a capped phase (#739): a capped result is
|
|
122
|
+
* incomplete-but-not-hard-failed, so its partial work must survive downstream.
|
|
123
|
+
* A genuine (non-capped) failure keeps the historical behaviour of dropping
|
|
124
|
+
* `output`, leaving the `/loop` fix-context (`formatFailureContext`) unchanged.
|
|
125
|
+
*
|
|
126
|
+
* @internal Exported for testing only
|
|
127
|
+
*/
|
|
128
|
+
export declare function mapAgentFailureToPhaseResult(phase: Phase, agentResult: AgentPhaseResult, durationSeconds: number): PhaseResult & {
|
|
129
|
+
sessionId?: string;
|
|
130
|
+
resumeHandle?: ResumeHandle;
|
|
131
|
+
};
|
|
114
132
|
/**
|
|
115
133
|
* Get the prompt for a phase with the issue number substituted.
|
|
116
134
|
* Selects self-contained prompts for non-Claude agents.
|