sequant 2.8.0 → 2.10.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 +23 -4
- package/dist/bin/cli.js +99 -18
- package/dist/marketplace/external_plugins/sequant/.claude-plugin/plugin.json +1 -1
- package/dist/marketplace/external_plugins/sequant/.mcp.json +1 -1
- package/dist/marketplace/external_plugins/sequant/README.md +58 -2
- package/dist/marketplace/external_plugins/sequant/hooks/parallel-marker.sh +50 -0
- package/dist/marketplace/external_plugins/sequant/hooks/post-tool.sh +38 -10
- package/dist/marketplace/external_plugins/sequant/hooks/pre-tool.sh +405 -67
- package/dist/marketplace/external_plugins/sequant/skills/_shared/references/trust-model.md +18 -0
- package/dist/marketplace/external_plugins/sequant/skills/assess/SKILL.md +250 -344
- package/dist/marketplace/external_plugins/sequant/skills/assess/references/predicted-collision-detection.md +10 -7
- package/dist/marketplace/external_plugins/sequant/skills/docs/SKILL.md +4 -4
- package/dist/marketplace/external_plugins/sequant/skills/exec/SKILL.md +26 -6
- package/dist/marketplace/external_plugins/sequant/skills/fullsolve/SKILL.md +4 -2
- package/dist/marketplace/external_plugins/sequant/skills/loop/SKILL.md +38 -2
- package/dist/marketplace/external_plugins/sequant/skills/merger/SKILL.md +2 -2
- package/dist/marketplace/external_plugins/sequant/skills/qa/SKILL.md +337 -57
- package/dist/marketplace/external_plugins/sequant/skills/qa/references/anti-pattern-detection.md +6 -6
- package/dist/marketplace/external_plugins/sequant/skills/qa/references/call-site-review.md +2 -2
- package/dist/marketplace/external_plugins/sequant/skills/qa/references/code-review-checklist.md +2 -2
- package/dist/marketplace/external_plugins/sequant/skills/qa/references/fixtures/injection-issue-body.md +36 -0
- package/dist/marketplace/external_plugins/sequant/skills/qa/references/quality-gates.md +21 -7
- package/dist/marketplace/external_plugins/sequant/skills/qa/references/testing-requirements.md +1 -1
- package/dist/marketplace/external_plugins/sequant/skills/qa/scripts/quality-checks.sh +11 -11
- package/dist/marketplace/external_plugins/sequant/skills/reflect/SKILL.md +27 -13
- package/dist/marketplace/external_plugins/sequant/skills/reflect/references/documentation-tiers.md +80 -68
- package/dist/marketplace/external_plugins/sequant/skills/reflect/references/phase-reflection.md +31 -15
- package/dist/marketplace/external_plugins/sequant/skills/release/SKILL.md +11 -3
- package/dist/marketplace/external_plugins/sequant/skills/setup/SKILL.md +5 -5
- package/dist/marketplace/external_plugins/sequant/skills/spec/SKILL.md +2 -0
- package/dist/marketplace/external_plugins/sequant/skills/spec/references/verification-criteria.md +1 -1
- package/dist/marketplace/external_plugins/sequant/skills/test/SKILL.md +1 -1
- package/dist/src/commands/assess-render.d.ts +23 -0
- package/dist/src/commands/assess-render.js +60 -0
- package/dist/src/commands/doctor.js +8 -13
- package/dist/src/commands/init.js +13 -1
- package/dist/src/commands/locks.js +2 -0
- package/dist/src/commands/logs.js +20 -6
- package/dist/src/commands/merge.js +154 -2
- package/dist/src/commands/ready-tui-adapter.js +6 -1
- package/dist/src/commands/ready.d.ts +25 -3
- package/dist/src/commands/ready.js +39 -13
- package/dist/src/commands/resume.d.ts +113 -0
- package/dist/src/commands/resume.js +193 -0
- package/dist/src/commands/run-display.d.ts +20 -0
- package/dist/src/commands/run-display.js +117 -5
- package/dist/src/commands/run-flags.d.ts +42 -1
- package/dist/src/commands/run-flags.js +53 -1
- package/dist/src/commands/run-progress.js +26 -1
- package/dist/src/commands/run.js +11 -10
- package/dist/src/commands/state.js +7 -0
- package/dist/src/commands/stats.js +47 -0
- package/dist/src/commands/status.d.ts +9 -0
- package/dist/src/commands/status.js +24 -1
- package/dist/src/commands/sync.js +90 -19
- package/dist/src/commands/update.js +28 -5
- package/dist/src/lib/ac-parser.d.ts +14 -0
- package/dist/src/lib/ac-parser.js +99 -5
- package/dist/src/lib/assess/index.d.ts +10 -0
- package/dist/src/lib/assess/index.js +9 -0
- package/dist/src/lib/assess/renderer.d.ts +64 -0
- package/dist/src/lib/assess/renderer.js +481 -0
- package/dist/src/lib/assess/types.d.ts +224 -0
- package/dist/src/lib/assess/types.js +241 -0
- package/dist/src/lib/assess-collision-detect.d.ts +19 -2
- package/dist/src/lib/assess-collision-detect.js +68 -4
- package/dist/src/lib/ci/outputs.d.ts +15 -2
- package/dist/src/lib/ci/outputs.js +17 -5
- package/dist/src/lib/cli-flags.d.ts +33 -0
- package/dist/src/lib/cli-flags.js +46 -0
- package/dist/src/lib/cli-ui/run-renderer-types.d.ts +21 -7
- package/dist/src/lib/cli-ui/run-renderer.d.ts +10 -0
- package/dist/src/lib/cli-ui/run-renderer.js +79 -9
- package/dist/src/lib/errors.d.ts +84 -6
- package/dist/src/lib/errors.js +142 -17
- package/dist/src/lib/locks/index.d.ts +3 -3
- package/dist/src/lib/locks/index.js +2 -2
- package/dist/src/lib/locks/lock-manager.d.ts +20 -2
- package/dist/src/lib/locks/lock-manager.js +47 -3
- package/dist/src/lib/locks/types.d.ts +34 -3
- package/dist/src/lib/locks/types.js +16 -0
- package/dist/src/lib/manifest.js +1 -17
- package/dist/src/lib/mcp-config.d.ts +45 -0
- package/dist/src/lib/mcp-config.js +77 -1
- package/dist/src/lib/merge-check/combined-branch-test.d.ts +21 -2
- package/dist/src/lib/merge-check/combined-branch-test.js +233 -110
- package/dist/src/lib/merge-check/command-result.d.ts +36 -0
- package/dist/src/lib/merge-check/command-result.js +56 -0
- package/dist/src/lib/merge-check/index.d.ts +4 -0
- package/dist/src/lib/merge-check/index.js +1 -1
- package/dist/src/lib/merge-check/report.js +5 -1
- package/dist/src/lib/merge-check/types.d.ts +9 -0
- package/dist/src/lib/merge-check/watch.d.ts +121 -0
- package/dist/src/lib/merge-check/watch.js +215 -0
- package/dist/src/lib/qa/infra-blocked-ci.d.ts +90 -0
- package/dist/src/lib/qa/infra-blocked-ci.js +80 -0
- package/dist/src/lib/settings.d.ts +14 -0
- package/dist/src/lib/settings.js +61 -8
- package/dist/src/lib/shutdown.d.ts +42 -2
- package/dist/src/lib/shutdown.js +75 -5
- package/dist/src/lib/skills-check.d.ts +26 -0
- package/dist/src/lib/skills-check.js +31 -0
- package/dist/src/lib/stacks.d.ts +110 -1
- package/dist/src/lib/stacks.js +244 -5
- package/dist/src/lib/templates.d.ts +74 -0
- package/dist/src/lib/templates.js +171 -16
- package/dist/src/lib/test-tautology-detector.d.ts +9 -2
- package/dist/src/lib/test-tautology-detector.js +153 -12
- package/dist/src/lib/version-check.d.ts +31 -0
- package/dist/src/lib/version-check.js +46 -7
- package/dist/src/lib/workflow/batch-executor.d.ts +138 -6
- package/dist/src/lib/workflow/batch-executor.js +550 -54
- package/dist/src/lib/workflow/chain-preflight.d.ts +89 -0
- package/dist/src/lib/workflow/chain-preflight.js +199 -0
- package/dist/src/lib/workflow/chain-resume.d.ts +118 -0
- package/dist/src/lib/workflow/chain-resume.js +170 -0
- package/dist/src/lib/workflow/completed-status.d.ts +52 -0
- package/dist/src/lib/workflow/completed-status.js +57 -0
- package/dist/src/lib/workflow/config-resolver.d.ts +26 -0
- package/dist/src/lib/workflow/config-resolver.js +54 -2
- package/dist/src/lib/workflow/dependency-markers.d.ts +29 -0
- package/dist/src/lib/workflow/dependency-markers.js +79 -0
- package/dist/src/lib/workflow/drivers/agent-driver.d.ts +7 -0
- package/dist/src/lib/workflow/drivers/aider.d.ts +2 -0
- package/dist/src/lib/workflow/drivers/aider.js +2 -0
- package/dist/src/lib/workflow/drivers/claude-code.d.ts +21 -0
- package/dist/src/lib/workflow/drivers/claude-code.js +50 -8
- package/dist/src/lib/workflow/error-classifier.d.ts +9 -2
- package/dist/src/lib/workflow/error-classifier.js +14 -1
- package/dist/src/lib/workflow/git-diff-utils.d.ts +35 -3
- package/dist/src/lib/workflow/git-diff-utils.js +77 -4
- package/dist/src/lib/workflow/heartbeat.d.ts +38 -0
- package/dist/src/lib/workflow/heartbeat.js +90 -0
- package/dist/src/lib/workflow/log-writer.d.ts +44 -1
- package/dist/src/lib/workflow/log-writer.js +81 -14
- package/dist/src/lib/workflow/metrics-schema.d.ts +42 -3
- package/dist/src/lib/workflow/metrics-schema.js +16 -0
- package/dist/src/lib/workflow/metrics-writer.d.ts +2 -1
- package/dist/src/lib/workflow/phase-executor.d.ts +234 -13
- package/dist/src/lib/workflow/phase-executor.js +533 -56
- package/dist/src/lib/workflow/phase-registry.js +2 -1
- package/dist/src/lib/workflow/platforms/github.d.ts +64 -0
- package/dist/src/lib/workflow/platforms/github.js +152 -0
- package/dist/src/lib/workflow/qa-cache.d.ts +6 -1
- package/dist/src/lib/workflow/qa-cache.js +22 -9
- package/dist/src/lib/workflow/ready-gate.d.ts +17 -4
- package/dist/src/lib/workflow/ready-gate.js +25 -9
- package/dist/src/lib/workflow/run-log-schema.d.ts +37 -0
- package/dist/src/lib/workflow/run-log-schema.js +92 -2
- package/dist/src/lib/workflow/run-orchestrator.d.ts +33 -0
- package/dist/src/lib/workflow/run-orchestrator.js +429 -47
- package/dist/src/lib/workflow/skills-preflight.d.ts +89 -0
- package/dist/src/lib/workflow/skills-preflight.js +112 -0
- package/dist/src/lib/workflow/state-cleanup.js +9 -2
- package/dist/src/lib/workflow/state-manager.d.ts +33 -0
- package/dist/src/lib/workflow/state-manager.js +83 -0
- package/dist/src/lib/workflow/state-schema.d.ts +26 -0
- package/dist/src/lib/workflow/state-schema.js +47 -0
- package/dist/src/lib/workflow/status-derivation.d.ts +30 -0
- package/dist/src/lib/workflow/status-derivation.js +27 -0
- package/dist/src/lib/workflow/types.d.ts +149 -4
- package/dist/src/lib/workflow/types.js +1 -0
- package/dist/src/lib/workflow/worktree-manager.d.ts +70 -2
- package/dist/src/lib/workflow/worktree-manager.js +201 -52
- package/dist/src/mcp/tools/run.d.ts +11 -1
- package/dist/src/mcp/tools/run.js +22 -1
- package/dist/src/mcp/tools/status.js +6 -0
- package/dist/src/ui/tui/ElapsedTimer.d.ts +12 -3
- package/dist/src/ui/tui/ElapsedTimer.js +12 -10
- package/dist/src/ui/tui/IssueBox.js +10 -2
- package/dist/src/ui/tui/row-cap.js +2 -1
- package/dist/src/ui/tui/theme.d.ts +7 -0
- package/dist/src/ui/tui/theme.js +9 -0
- package/package.json +9 -10
- package/templates/hooks/parallel-marker.sh +50 -0
- package/templates/hooks/post-tool.sh +38 -10
- package/templates/hooks/pre-tool.sh +405 -67
- package/templates/scripts/cleanup-worktree.sh +282 -30
- package/templates/scripts/new-feature.sh +264 -16
- package/templates/skills/_shared/references/trust-model.md +18 -0
- package/templates/skills/assess/SKILL.md +250 -344
- package/templates/skills/assess/references/predicted-collision-detection.md +10 -7
- package/templates/skills/docs/SKILL.md +4 -4
- package/templates/skills/exec/SKILL.md +26 -6
- package/templates/skills/fullsolve/SKILL.md +4 -2
- package/templates/skills/loop/SKILL.md +38 -2
- package/templates/skills/merger/SKILL.md +2 -2
- package/templates/skills/qa/SKILL.md +337 -57
- package/templates/skills/qa/references/anti-pattern-detection.md +6 -6
- package/templates/skills/qa/references/call-site-review.md +2 -2
- package/templates/skills/qa/references/code-review-checklist.md +2 -2
- package/templates/skills/qa/references/fixtures/injection-issue-body.md +36 -0
- package/templates/skills/qa/references/quality-gates.md +21 -7
- package/templates/skills/qa/references/testing-requirements.md +1 -1
- package/templates/skills/qa/scripts/quality-checks.sh +11 -11
- package/templates/skills/reflect/SKILL.md +27 -13
- package/templates/skills/reflect/references/documentation-tiers.md +80 -68
- package/templates/skills/reflect/references/phase-reflection.md +31 -15
- package/templates/skills/release/SKILL.md +11 -3
- package/templates/skills/setup/SKILL.md +5 -5
- package/templates/skills/spec/SKILL.md +2 -0
- package/templates/skills/spec/references/verification-criteria.md +1 -1
- package/templates/skills/test/SKILL.md +1 -1
- package/dist/src/lib/phase-spinner.d.ts +0 -146
- package/dist/src/lib/phase-spinner.js +0 -255
- package/dist/src/lib/workflow/pr-operations.d.ts +0 -86
- package/dist/src/lib/workflow/pr-operations.js +0 -326
- package/dist/src/lib/workflow/run-summary.d.ts +0 -36
- package/dist/src/lib/workflow/run-summary.js +0 -142
|
@@ -8,11 +8,13 @@
|
|
|
8
8
|
* is agent-agnostic.
|
|
9
9
|
*/
|
|
10
10
|
import chalk from "chalk";
|
|
11
|
-
import {
|
|
11
|
+
import { execFileSync } from "child_process";
|
|
12
|
+
import { resolveDiffBase } from "./git-diff-utils.js";
|
|
12
13
|
import { readAgentsMd } from "../agents-md.js";
|
|
13
14
|
import { getDriver } from "./drivers/index.js";
|
|
14
15
|
import { classifyError } from "./error-classifier.js";
|
|
15
|
-
import { ApiError, BillingError } from "../errors.js";
|
|
16
|
+
import { ApiError, BillingError, RateLimitError, formatRateLimitMessage, formatResetTime, resetsAtToMs, } from "../errors.js";
|
|
17
|
+
import { formatElapsedTime } from "../cli-ui/format.js";
|
|
16
18
|
import { phaseRegistry } from "./phase-registry.js";
|
|
17
19
|
import { bracketedConsoleLog } from "./notice.js";
|
|
18
20
|
/**
|
|
@@ -98,6 +100,279 @@ const SPEC_RETRY_STRATEGY = phaseRegistry.get("spec").retryStrategy;
|
|
|
98
100
|
export const SPEC_RETRY_BACKOFF_MS = SPEC_RETRY_STRATEGY?.backoffMs ?? 5000;
|
|
99
101
|
/** @internal Exported for testing only */
|
|
100
102
|
export const SPEC_EXTRA_RETRIES = SPEC_RETRY_STRATEGY?.extraRetries ?? 1;
|
|
103
|
+
/**
|
|
104
|
+
* A rate limit whose window resets further out than this is treated as
|
|
105
|
+
* exhausted rather than transient (#761 AC-2): no retry can succeed inside a
|
|
106
|
+
* closed window, so consuming cold-start retries (each burning up to a full
|
|
107
|
+
* `phaseTimeout`) only delays the labeled halt. Five minutes comfortably
|
|
108
|
+
* exceeds any backoff this executor performs while staying far below the
|
|
109
|
+
* five-hour/seven-day windows the check exists to catch.
|
|
110
|
+
*
|
|
111
|
+
* @internal Exported for testing only
|
|
112
|
+
*/
|
|
113
|
+
export const RATE_LIMIT_WINDOW_SKIP_THRESHOLD_MS = 5 * 60 * 1000;
|
|
114
|
+
/**
|
|
115
|
+
* Base backoff for transient rate-limit retries (#761 AC-4), doubled per
|
|
116
|
+
* attempt (5s, 10s). Same scale as `SPEC_RETRY_BACKOFF_MS` — long enough to
|
|
117
|
+
* outlive a momentary throttle, short enough to be negligible next to a
|
|
118
|
+
* phase's runtime.
|
|
119
|
+
*
|
|
120
|
+
* @internal Exported for testing only
|
|
121
|
+
*/
|
|
122
|
+
export const RATE_LIMIT_RETRY_BACKOFF_MS = 5000;
|
|
123
|
+
/**
|
|
124
|
+
* True when a failure is a rate limit whose reset lies beyond
|
|
125
|
+
* {@link RATE_LIMIT_WINDOW_SKIP_THRESHOLD_MS} — i.e. window exhaustion, not a
|
|
126
|
+
* transient throttle. Metadata-absent rate limits (the assistant-error channel
|
|
127
|
+
* carries no `resetsAt`, see #761 AC-9) return false and fall through to the
|
|
128
|
+
* transient path: with no timing signal, retry-with-backoff is the safe
|
|
129
|
+
* default, skipping all retries is not.
|
|
130
|
+
*
|
|
131
|
+
* @internal Exported for testing only
|
|
132
|
+
*/
|
|
133
|
+
export function isWindowExhaustedRateLimit(error, now = Date.now()) {
|
|
134
|
+
if (!(error instanceof RateLimitError))
|
|
135
|
+
return false;
|
|
136
|
+
const resetsAt = error.metadata.resetsAt;
|
|
137
|
+
if (typeof resetsAt !== "number")
|
|
138
|
+
return false;
|
|
139
|
+
return resetsAtToMs(resetsAt) - now > RATE_LIMIT_WINDOW_SKIP_THRESHOLD_MS;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Buffer added to a rate-limit reset before waking (#804 AC-5). `resetsAt` is a
|
|
143
|
+
* floor, not an exact moment: clock skew between this host and the API, plus
|
|
144
|
+
* server-side rounding, otherwise produce an immediate second rejection on
|
|
145
|
+
* wake. One minute is negligible against the five-hour/seven-day windows this
|
|
146
|
+
* exists for.
|
|
147
|
+
*
|
|
148
|
+
* @internal Exported for testing only
|
|
149
|
+
*/
|
|
150
|
+
export const AUTO_WAIT_BUFFER_MS = 60 * 1000;
|
|
151
|
+
/**
|
|
152
|
+
* Hard cap on auto-waits per issue (#804 AC-6), independent of the minutes
|
|
153
|
+
* budget. A window that is still closed on wake must not produce an unbounded
|
|
154
|
+
* pause loop, so the count bounds the *number* of pauses while
|
|
155
|
+
* `autoWaitMinutes` bounds their *total duration*. Either bound being spent
|
|
156
|
+
* halts with today's labeled message.
|
|
157
|
+
*
|
|
158
|
+
* @internal Exported for testing only
|
|
159
|
+
*/
|
|
160
|
+
export const AUTO_WAIT_MAX_WAITS = 2;
|
|
161
|
+
/**
|
|
162
|
+
* Granularity of the auto-wait sleep (#804 AC-7). The wait is performed as a
|
|
163
|
+
* series of ticks rather than one multi-hour `delayFn` call so that (a) the
|
|
164
|
+
* renderer/heartbeat can be refreshed with the remaining time, and (b) a
|
|
165
|
+
* Ctrl-C is observed promptly instead of at the wake time.
|
|
166
|
+
*
|
|
167
|
+
* @internal Exported for testing only
|
|
168
|
+
*/
|
|
169
|
+
export const AUTO_WAIT_TICK_MS = 15 * 1000;
|
|
170
|
+
/**
|
|
171
|
+
* Build a fresh ledger from a minutes budget. A missing, negative or
|
|
172
|
+
* non-finite budget yields a disabled ledger (`budgetMs: 0`) — the default,
|
|
173
|
+
* which preserves pre-#804 behavior exactly.
|
|
174
|
+
*/
|
|
175
|
+
export function createAutoWaitLedger(budgetMinutes) {
|
|
176
|
+
const minutes = typeof budgetMinutes === "number" && Number.isFinite(budgetMinutes)
|
|
177
|
+
? Math.max(0, budgetMinutes)
|
|
178
|
+
: 0;
|
|
179
|
+
return { budgetMs: minutes * 60 * 1000, waits: 0, spentMs: 0 };
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Decide whether to wait out an exhausted rate-limit window (#804 AC-3).
|
|
183
|
+
*
|
|
184
|
+
* Deliberately separate from {@link isWindowExhaustedRateLimit} and
|
|
185
|
+
* {@link RATE_LIMIT_WINDOW_SKIP_THRESHOLD_MS}, which answer *"is this transient
|
|
186
|
+
* or exhausted?"*. This answers a different question — *"am I willing to wait
|
|
187
|
+
* that long?"* — and fires ONLY once the former is already true. Keeping them
|
|
188
|
+
* apart is what leaves `autoWaitMinutes` the single user-facing dial for the
|
|
189
|
+
* wait-vs-halt outcome.
|
|
190
|
+
*
|
|
191
|
+
* Returns `null` (no wait — halt as before) when:
|
|
192
|
+
* - auto-wait is off, or the per-issue wait count is spent;
|
|
193
|
+
* - the failure is not a window-exhausted `RateLimitError`. A `BillingError`
|
|
194
|
+
* lands here: it is a sibling class, not a subclass, so the `instanceof`
|
|
195
|
+
* check inside `isWindowExhaustedRateLimit` excludes it. That is load-bearing
|
|
196
|
+
* — a `BillingError` may still carry `resetsAt` (an explicit
|
|
197
|
+
* `credits_required`, or a window that has already passed — see the #860
|
|
198
|
+
* narrowing in `isBillingFailure`), so gating on the timestamp's presence
|
|
199
|
+
* instead of the error type would wait out a credits failure that no amount
|
|
200
|
+
* of waiting can heal (AC-4). Since #860, a *live* recognized window
|
|
201
|
+
* (`five_hour`/`seven_day*` + future reset) classifies as `RateLimitError`
|
|
202
|
+
* upstream even when `out_of_credits` is present, which is what lets the
|
|
203
|
+
* real captured subscription payloads reach this decision at all;
|
|
204
|
+
* - the reset has already passed (nothing to wait for);
|
|
205
|
+
* - the required wait exceeds the budget REMAINING, not the total (AC-6).
|
|
206
|
+
*
|
|
207
|
+
* @internal Exported for testing only
|
|
208
|
+
*/
|
|
209
|
+
export function shouldAutoWaitForReset(error, ledger, now = Date.now()) {
|
|
210
|
+
if (ledger.budgetMs <= 0)
|
|
211
|
+
return null;
|
|
212
|
+
if (ledger.waits >= AUTO_WAIT_MAX_WAITS)
|
|
213
|
+
return null;
|
|
214
|
+
// These two guards duplicate checks inside `isWindowExhaustedRateLimit`, and
|
|
215
|
+
// that duplication is deliberate: TypeScript cannot narrow through a boolean
|
|
216
|
+
// helper, so without them the lines below need `as RateLimitError` /
|
|
217
|
+
// `as number` casts. Real narrowing beats a cast in the one function that
|
|
218
|
+
// decides whether to pause a run for hours. The classifier below still owns
|
|
219
|
+
// the "transient or exhausted?" question (AC-3) — these only prove types.
|
|
220
|
+
if (!(error instanceof RateLimitError))
|
|
221
|
+
return null;
|
|
222
|
+
const resetsAt = error.metadata.resetsAt;
|
|
223
|
+
if (typeof resetsAt !== "number")
|
|
224
|
+
return null;
|
|
225
|
+
if (!isWindowExhaustedRateLimit(error, now))
|
|
226
|
+
return null;
|
|
227
|
+
// All arithmetic stays in epoch ms (AC-5) — resetsAtToMs first, buffer
|
|
228
|
+
// after, formatting only ever for display.
|
|
229
|
+
const wakeAtMs = resetsAtToMs(resetsAt) + AUTO_WAIT_BUFFER_MS;
|
|
230
|
+
const waitMs = wakeAtMs - now;
|
|
231
|
+
if (waitMs <= 0)
|
|
232
|
+
return null;
|
|
233
|
+
const remainingMs = ledger.budgetMs - ledger.spentMs;
|
|
234
|
+
if (waitMs > remainingMs)
|
|
235
|
+
return null;
|
|
236
|
+
// Carry the narrowed error forward so callers don't have to re-cast it.
|
|
237
|
+
return { waitMs, wakeAtMs, error };
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Sleep until an auto-wait's wake time, in ticks (#804 AC-7).
|
|
241
|
+
*
|
|
242
|
+
* Chunking the sleep is what makes a multi-hour pause survivable:
|
|
243
|
+
* - `onTick` refreshes the live display so the wait is visible rather than a
|
|
244
|
+
* silent stall (the #574 complaint at 60x scale);
|
|
245
|
+
* - each tick races the injected `delayFn` against the abort signal, so Ctrl-C
|
|
246
|
+
* returns immediately instead of blocking until the wake.
|
|
247
|
+
*
|
|
248
|
+
* Reuses the caller's `delayFn` so the wait stays fully test-injectable.
|
|
249
|
+
*
|
|
250
|
+
* Returns the ms actually slept and whether the wait was aborted.
|
|
251
|
+
*
|
|
252
|
+
* @internal Exported for testing only
|
|
253
|
+
*/
|
|
254
|
+
export async function waitForWindowReset(waitMs, options) {
|
|
255
|
+
const { delayFn, signal, onTick } = options;
|
|
256
|
+
const now = options.now ?? Date.now;
|
|
257
|
+
const tickMs = options.tickMs ?? AUTO_WAIT_TICK_MS;
|
|
258
|
+
const startedAt = now();
|
|
259
|
+
const deadline = startedAt + waitMs;
|
|
260
|
+
// Two independent notions of "how much is left", and the loop honors
|
|
261
|
+
// whichever expires first:
|
|
262
|
+
// - `budgetLeft` counts down by the slices actually requested. This is what
|
|
263
|
+
// terminates the loop, and it does so after a fixed number of iterations
|
|
264
|
+
// regardless of what the clock does — without it, an injected delayFn
|
|
265
|
+
// that resolves instantly (every test) would spin against the wall clock
|
|
266
|
+
// until the real reset time arrived.
|
|
267
|
+
// - the `deadline` check ends the wait early when real time has outrun the
|
|
268
|
+
// slices, e.g. a laptop resumed from sleep mid-wait.
|
|
269
|
+
let budgetLeft = waitMs;
|
|
270
|
+
let sleptMs = 0;
|
|
271
|
+
// ONE abort listener for the whole wait, not one per tick. A 5-hour wait is
|
|
272
|
+
// ~1200 ticks; registering inside the loop would pile up that many listeners
|
|
273
|
+
// (and as many pending promises) on a single signal, tripping Node's
|
|
274
|
+
// max-listeners warning and leaking until the wait ended.
|
|
275
|
+
const abortPromise = signal
|
|
276
|
+
? new Promise((resolve) => {
|
|
277
|
+
signal.addEventListener("abort", () => resolve(true), { once: true });
|
|
278
|
+
})
|
|
279
|
+
: null;
|
|
280
|
+
for (;;) {
|
|
281
|
+
if (signal?.aborted) {
|
|
282
|
+
return { sleptMs: Math.max(sleptMs, now() - startedAt), aborted: true };
|
|
283
|
+
}
|
|
284
|
+
const clockLeft = deadline - now();
|
|
285
|
+
const remainingMs = Math.min(budgetLeft, clockLeft);
|
|
286
|
+
if (remainingMs <= 0)
|
|
287
|
+
break;
|
|
288
|
+
onTick?.(remainingMs);
|
|
289
|
+
const sliceMs = Math.min(tickMs, remainingMs);
|
|
290
|
+
budgetLeft -= sliceMs;
|
|
291
|
+
sleptMs += sliceMs;
|
|
292
|
+
// The injected delayFn exposes no timer handle, so an in-flight slice
|
|
293
|
+
// cannot be cleared on abort. Racing bounds the *observed* Ctrl-C latency
|
|
294
|
+
// to ~0 regardless; only the orphaned timer runs on, and it is one tick
|
|
295
|
+
// long, not one window long.
|
|
296
|
+
if (abortPromise) {
|
|
297
|
+
const aborted = await Promise.race([
|
|
298
|
+
delayFn(sliceMs).then(() => false),
|
|
299
|
+
abortPromise,
|
|
300
|
+
]);
|
|
301
|
+
if (aborted) {
|
|
302
|
+
// This slice never completed — don't bill the ledger for it.
|
|
303
|
+
sleptMs -= sliceMs;
|
|
304
|
+
return { sleptMs: Math.max(sleptMs, now() - startedAt), aborted: true };
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
await delayFn(sliceMs);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
sleptMs: Math.max(sleptMs, now() - startedAt),
|
|
313
|
+
aborted: signal?.aborted === true,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Run one granted auto-wait end to end (#804): emit the live notices, sleep,
|
|
318
|
+
* update the ledger, and report whether Ctrl-C interrupted it.
|
|
319
|
+
*
|
|
320
|
+
* Shared by both arms of the retry ladder (the cold-start loop and the
|
|
321
|
+
* `skipColdStartRetry` single-attempt path) so the two cannot drift on
|
|
322
|
+
* bookkeeping or messaging.
|
|
323
|
+
*
|
|
324
|
+
* @internal Exported for testing only
|
|
325
|
+
*/
|
|
326
|
+
export async function performAutoWait(issueNumber, phase, config, decision, ledger, delayFn, shutdownManager, spinner) {
|
|
327
|
+
// Count the wait BEFORE sleeping. If the process dies mid-wait the ledger is
|
|
328
|
+
// gone anyway, but an exception on the sleep path must not hand back a free
|
|
329
|
+
// retry — the bound exists to stop unbounded pause loops.
|
|
330
|
+
ledger.waits += 1;
|
|
331
|
+
// AC-5: reuse the driver's own formatter for the cause rather than
|
|
332
|
+
// re-deriving a timestamp, and render the wake time (a distinct value —
|
|
333
|
+
// reset plus buffer) through the same `formatResetTime` convention.
|
|
334
|
+
const cause = formatRateLimitMessage(decision.error.metadata);
|
|
335
|
+
const wakeLabel = formatResetTime(decision.wakeAtMs);
|
|
336
|
+
const header = `${cause} · auto-wait ${ledger.waits}/${AUTO_WAIT_MAX_WAITS} — resuming at ${wakeLabel}`;
|
|
337
|
+
bracketedConsoleLog(spinner, chalk.yellow(`\n ⏸ ${header} (${formatElapsedTime(decision.waitMs / 1000)})`));
|
|
338
|
+
const emit = (remainingMs, done) => {
|
|
339
|
+
try {
|
|
340
|
+
config.onAutoWait?.({
|
|
341
|
+
issueNumber,
|
|
342
|
+
phase,
|
|
343
|
+
wakeAtMs: decision.wakeAtMs,
|
|
344
|
+
remainingMs,
|
|
345
|
+
message: done
|
|
346
|
+
? `${cause} · auto-wait complete`
|
|
347
|
+
: `${header} · ${formatElapsedTime(remainingMs / 1000)} left`,
|
|
348
|
+
done,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
// Liveness notices must never disrupt the run.
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
// AC-7: a registered controller is aborted by ShutdownManager on SIGINT, so
|
|
356
|
+
// Ctrl-C ends the wait instead of blocking until the wake.
|
|
357
|
+
const controller = new AbortController();
|
|
358
|
+
shutdownManager?.addAbortController(controller);
|
|
359
|
+
try {
|
|
360
|
+
const { sleptMs, aborted } = await waitForWindowReset(decision.waitMs, {
|
|
361
|
+
delayFn,
|
|
362
|
+
signal: controller.signal,
|
|
363
|
+
onTick: (remainingMs) => emit(remainingMs, false),
|
|
364
|
+
});
|
|
365
|
+
ledger.spentMs += sleptMs;
|
|
366
|
+
emit(0, true);
|
|
367
|
+
if (aborted) {
|
|
368
|
+
bracketedConsoleLog(spinner, chalk.yellow(` ✕ Auto-wait interrupted — halting`));
|
|
369
|
+
}
|
|
370
|
+
return { aborted };
|
|
371
|
+
}
|
|
372
|
+
finally {
|
|
373
|
+
shutdownManager?.removeAbortController(controller);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
101
376
|
export function parseQaVerdict(output) {
|
|
102
377
|
if (!output)
|
|
103
378
|
return null;
|
|
@@ -122,6 +397,59 @@ export function parseQaVerdict(output) {
|
|
|
122
397
|
const verdict = verdictMatch[1].toUpperCase().replace(/-/g, "_");
|
|
123
398
|
return verdict;
|
|
124
399
|
}
|
|
400
|
+
/**
|
|
401
|
+
* Deferral/continuation markers that signal a QA agent ended its turn intending
|
|
402
|
+
* to continue *later* — as if a subsequent turn or a background poll were
|
|
403
|
+
* available to it. Seeded from the #853 live repro, whose QA turn deferred to a
|
|
404
|
+
* background CI poll and "the completion notification" instead of emitting a
|
|
405
|
+
* verdict. Matched as lowercased literal substrings (ReDoS-safe) against the
|
|
406
|
+
* output tail only.
|
|
407
|
+
*
|
|
408
|
+
* The second group covers deferral-to-*user* phrasing, observed by running the
|
|
409
|
+
* detector against every `qa` phase output in 99 real `.sequant/logs` runs: a
|
|
410
|
+
* turn that ended "this needs a decision from you … Tell me which one and I'll
|
|
411
|
+
* execute it" (run-2026-06-06) is the same one-shot violation with the wait
|
|
412
|
+
* pointed at a human instead of a poll. Structural alternatives were tried
|
|
413
|
+
* against the same corpus and rejected: 19/45 outputs with a *parseable*
|
|
414
|
+
* verdict have no `Verdict`-labelled line at all, so label-presence cannot
|
|
415
|
+
* discriminate — a literal marker list is the shape that works here.
|
|
416
|
+
*/
|
|
417
|
+
const QA_DEFERRAL_MARKERS = [
|
|
418
|
+
"i'll pick this up",
|
|
419
|
+
"pick this up on the completion",
|
|
420
|
+
"completion notification",
|
|
421
|
+
"background poll",
|
|
422
|
+
"when the background",
|
|
423
|
+
"when ci ",
|
|
424
|
+
"later turn",
|
|
425
|
+
"i'll invoke",
|
|
426
|
+
// Deferral-to-user (corpus-observed, run-2026-06-06T03-33-20):
|
|
427
|
+
"needs a decision from you",
|
|
428
|
+
"tell me which",
|
|
429
|
+
"let me know which",
|
|
430
|
+
"tell me and i'll",
|
|
431
|
+
];
|
|
432
|
+
/**
|
|
433
|
+
* Distinguish a QA turn that produced *no verdict at all* from one whose output
|
|
434
|
+
* was present but unparseable (#853). Returns true when the output is empty /
|
|
435
|
+
* whitespace, or when its tail contains deferral language — the agent treating
|
|
436
|
+
* its one-shot phase as if a later turn were available.
|
|
437
|
+
*
|
|
438
|
+
* Both cases are hard failures either way; this only refines which message is
|
|
439
|
+
* emitted, so a false positive is harmless (it swaps one failing message for
|
|
440
|
+
* another). Kept deliberately conservative and literal to stay ReDoS-safe.
|
|
441
|
+
*
|
|
442
|
+
* @internal Exported for testing only.
|
|
443
|
+
*/
|
|
444
|
+
export function endedWithoutVerdict(output) {
|
|
445
|
+
if (!output || output.trim().length === 0)
|
|
446
|
+
return true;
|
|
447
|
+
// Scan the tail only: deferral language lives at the end of the turn, where
|
|
448
|
+
// the agent signs off planning to continue. A larger window would also catch
|
|
449
|
+
// a mid-review aside that says "later" without deferring the whole turn.
|
|
450
|
+
const tail = output.slice(-2000).toLowerCase();
|
|
451
|
+
return QA_DEFERRAL_MARKERS.some((marker) => tail.includes(marker));
|
|
452
|
+
}
|
|
125
453
|
/**
|
|
126
454
|
* Parse condensed QA summary from QA phase output (#434).
|
|
127
455
|
*
|
|
@@ -275,26 +603,41 @@ export function resolveBaseRef(cwd) {
|
|
|
275
603
|
return recorded.startsWith("origin/") ? recorded : `origin/${recorded}`;
|
|
276
604
|
}
|
|
277
605
|
/**
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
606
|
+
* Parse the file paths out of `git status --porcelain` (v1) output. Each line
|
|
607
|
+
* is `XY <path>` (two status chars + a space); renames are `R <old> -> <new>`,
|
|
608
|
+
* for which we name the new path.
|
|
609
|
+
*/
|
|
610
|
+
function parsePorcelainPaths(porcelain) {
|
|
611
|
+
return porcelain
|
|
612
|
+
.split("\n")
|
|
613
|
+
.map((line) => line.replace(/\s+$/, ""))
|
|
614
|
+
.filter((line) => line.length > 0)
|
|
615
|
+
.map((line) => {
|
|
616
|
+
const rest = line.slice(3);
|
|
617
|
+
const arrow = rest.indexOf(" -> ");
|
|
618
|
+
return arrow >= 0 ? rest.slice(arrow + 4) : rest;
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Classify what the exec phase produced in the worktree (#879).
|
|
281
623
|
*
|
|
282
|
-
* Uses `git rev-list --count <base>..HEAD` (commits reachable from HEAD
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
624
|
+
* Uses `git rev-list --count <base>..HEAD` (commits reachable from HEAD but not
|
|
625
|
+
* the base) instead of `git diff <base>..HEAD`, because the two-dot diff also
|
|
626
|
+
* fires in reverse when the base has advanced past HEAD — on stale branches
|
|
627
|
+
* that would falsely report "has commits" even when exec produced nothing,
|
|
628
|
+
* reintroducing the bug #534 is fixing.
|
|
287
629
|
*
|
|
288
630
|
* The base ref defaults to `origin/main` but is overridden to the worktree's
|
|
289
|
-
* recorded base (see #537) so zero-diff execs are still detected on
|
|
290
|
-
*
|
|
631
|
+
* recorded base (see #537) so zero-diff execs are still detected on custom-base
|
|
632
|
+
* worktrees (e.g. those created with `--base feature/epic`).
|
|
291
633
|
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
634
|
+
* Read-only: runs only `git rev-list` and `git status --porcelain`, so it never
|
|
635
|
+
* mutates the worktree — an exec phase that fails on an `uncommitted` result
|
|
636
|
+
* leaves the dirty files exactly where the agent left them (#879 AC-3).
|
|
294
637
|
*
|
|
295
638
|
* @internal Exported for testing only.
|
|
296
639
|
*/
|
|
297
|
-
export function
|
|
640
|
+
export function classifyExecChanges(cwd) {
|
|
298
641
|
const baseRef = resolveBaseRef(cwd);
|
|
299
642
|
let commitsAhead;
|
|
300
643
|
try {
|
|
@@ -304,23 +647,53 @@ export function hasExecChanges(cwd) {
|
|
|
304
647
|
commitsAhead = Number.parseInt(count, 10) > 0;
|
|
305
648
|
}
|
|
306
649
|
catch {
|
|
307
|
-
return
|
|
650
|
+
return { kind: "unknown" };
|
|
308
651
|
}
|
|
309
652
|
if (commitsAhead)
|
|
310
|
-
return
|
|
653
|
+
return { kind: "commits" };
|
|
311
654
|
try {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
})
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
return porcelain.length > 0;
|
|
655
|
+
// `--untracked-files=all` lists each new file individually rather than
|
|
656
|
+
// collapsing a wholly-untracked directory to its top-level path — so the
|
|
657
|
+
// #879 failure message names the actual files the agent left behind (AC-2).
|
|
658
|
+
const porcelain = execFileSync("git", ["status", "--porcelain", "--untracked-files=all"], { cwd, stdio: "pipe" }).toString();
|
|
659
|
+
const paths = parsePorcelainPaths(porcelain);
|
|
660
|
+
return paths.length > 0 ? { kind: "uncommitted", paths } : { kind: "none" };
|
|
319
661
|
}
|
|
320
662
|
catch {
|
|
321
|
-
return
|
|
663
|
+
return { kind: "unknown" };
|
|
322
664
|
}
|
|
323
665
|
}
|
|
666
|
+
/**
|
|
667
|
+
* Check whether the exec phase produced deliverable work in the worktree.
|
|
668
|
+
*
|
|
669
|
+
* Thin boolean wrapper over {@link classifyExecChanges}: only `commits` (real
|
|
670
|
+
* work) and `unknown` (git error — fail open) count as "has changes". Note the
|
|
671
|
+
* #879 behaviour change: an `uncommitted`-only tree now returns **false**, since
|
|
672
|
+
* uncommitted work is not a deliverable. Both callers (the exec guard in
|
|
673
|
+
* {@link mapAgentSuccessToPhaseResult} and the ready gate) want this stricter
|
|
674
|
+
* semantics.
|
|
675
|
+
*
|
|
676
|
+
* @internal Exported for testing only.
|
|
677
|
+
*/
|
|
678
|
+
export function hasExecChanges(cwd) {
|
|
679
|
+
const { kind } = classifyExecChanges(cwd);
|
|
680
|
+
return kind === "commits" || kind === "unknown";
|
|
681
|
+
}
|
|
682
|
+
/** Cap on how many dirty paths the #879 exec-failure message lists inline. */
|
|
683
|
+
const MAX_UNCOMMITTED_PATHS_LISTED = 20;
|
|
684
|
+
/**
|
|
685
|
+
* Build the exec-failure message for an `uncommitted`-only worktree (#879 AC-2).
|
|
686
|
+
* Names the dirty paths so the work is discoverable from the run log alone,
|
|
687
|
+
* capped at {@link MAX_UNCOMMITTED_PATHS_LISTED} to keep the log bounded.
|
|
688
|
+
*/
|
|
689
|
+
function formatUncommittedExecError(paths, cwd) {
|
|
690
|
+
const listed = paths.slice(0, MAX_UNCOMMITTED_PATHS_LISTED);
|
|
691
|
+
const remainder = paths.length - listed.length;
|
|
692
|
+
const suffix = remainder > 0 ? `, … and ${remainder} more` : "";
|
|
693
|
+
return (`exec left ${paths.length} file(s) uncommitted and made no commits — ` +
|
|
694
|
+
`uncommitted work cannot rebase, push, or become a PR. The work is ` +
|
|
695
|
+
`preserved in ${cwd}: ${listed.join(", ")}${suffix}`);
|
|
696
|
+
}
|
|
324
697
|
/**
|
|
325
698
|
* Map a successful AgentPhaseResult to a PhaseResult, applying phase-specific
|
|
326
699
|
* guards that catch agent sessions which returned success without producing
|
|
@@ -348,9 +721,12 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
|
|
|
348
721
|
const summary = agentResult.output
|
|
349
722
|
? (parseQaSummary(agentResult.output) ?? undefined)
|
|
350
723
|
: undefined;
|
|
351
|
-
if (verdict
|
|
352
|
-
|
|
353
|
-
|
|
724
|
+
if (verdict === "AC_NOT_MET") {
|
|
725
|
+
// #749: only AC_NOT_MET (and the null branch below, #534) hard-fails.
|
|
726
|
+
// AC_MET_BUT_NOT_A_PLUS is a stopping/ready state — it must break to PR,
|
|
727
|
+
// not feed the quality loop (mirrors ready-gate.ts's `ac` policy). The
|
|
728
|
+
// verdict is retained on the success result so the PR/log surfaces the
|
|
729
|
+
// "not A+" note.
|
|
354
730
|
return {
|
|
355
731
|
phase,
|
|
356
732
|
success: false,
|
|
@@ -364,12 +740,20 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
|
|
|
364
740
|
};
|
|
365
741
|
}
|
|
366
742
|
if (!verdict) {
|
|
367
|
-
// #534: a null verdict
|
|
743
|
+
// #534: a null verdict is not success. #853 splits this into two classes
|
|
744
|
+
// so the message is not misleading. A turn that deferred to a later turn
|
|
745
|
+
// (or produced no output) never emitted a verdict — reporting that as
|
|
746
|
+
// "unparseable" sends a debugger to inspect a verdict regex that was
|
|
747
|
+
// never the problem. Both remain hard failures (`success:false`); only
|
|
748
|
+
// the message differs.
|
|
749
|
+
const error = endedWithoutVerdict(agentResult.output)
|
|
750
|
+
? "QA ended without producing a verdict — the phase is one-shot; the agent deferred to a later turn or emitted no output"
|
|
751
|
+
: "QA completed without a parseable verdict";
|
|
368
752
|
return {
|
|
369
753
|
phase,
|
|
370
754
|
success: false,
|
|
371
755
|
durationSeconds,
|
|
372
|
-
error
|
|
756
|
+
error,
|
|
373
757
|
...resume,
|
|
374
758
|
output: agentResult.output,
|
|
375
759
|
summary,
|
|
@@ -387,17 +771,38 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
|
|
|
387
771
|
...tails,
|
|
388
772
|
};
|
|
389
773
|
}
|
|
390
|
-
if (phase === "exec"
|
|
391
|
-
// #534: an exec phase that produced
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
774
|
+
if (phase === "exec") {
|
|
775
|
+
// #534/#879: an exec phase that produced no deliverable commits is not
|
|
776
|
+
// success. Two distinct non-deliverable states, each with its own message:
|
|
777
|
+
// - `none`: literally nothing (#534's empty-branch class).
|
|
778
|
+
// - `uncommitted`: a dirty tree with no commits — used to be counted as
|
|
779
|
+
// success (#879), but it cannot rebase, push, or become a PR. Failing
|
|
780
|
+
// here (rather than auto-committing) preserves the work in place; the
|
|
781
|
+
// message names the paths so it is discoverable from the run log alone.
|
|
782
|
+
// `commits`/`unknown` fall through to success (unknown fails open).
|
|
783
|
+
const changes = classifyExecChanges(cwd);
|
|
784
|
+
if (changes.kind === "none") {
|
|
785
|
+
return {
|
|
786
|
+
phase,
|
|
787
|
+
success: false,
|
|
788
|
+
durationSeconds,
|
|
789
|
+
error: "exec produced no changes (no commits, no uncommitted work)",
|
|
790
|
+
...resume,
|
|
791
|
+
output: agentResult.output,
|
|
792
|
+
...tails,
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
if (changes.kind === "uncommitted") {
|
|
796
|
+
return {
|
|
797
|
+
phase,
|
|
798
|
+
success: false,
|
|
799
|
+
durationSeconds,
|
|
800
|
+
error: formatUncommittedExecError(changes.paths, cwd),
|
|
801
|
+
...resume,
|
|
802
|
+
output: agentResult.output,
|
|
803
|
+
...tails,
|
|
804
|
+
};
|
|
805
|
+
}
|
|
401
806
|
}
|
|
402
807
|
return {
|
|
403
808
|
phase,
|
|
@@ -507,7 +912,8 @@ async function executePhase(issueNumber, phase, config, resumeHandle, worktreePa
|
|
|
507
912
|
let files;
|
|
508
913
|
if (config.agent && config.agent !== "claude-code") {
|
|
509
914
|
try {
|
|
510
|
-
const
|
|
915
|
+
const diffBase = resolveDiffBase(cwd, "main");
|
|
916
|
+
const output = execFileSync("git", ["diff", "--name-only", `${diffBase}...HEAD`], {
|
|
511
917
|
cwd,
|
|
512
918
|
encoding: "utf-8",
|
|
513
919
|
stdio: ["pipe", "pipe", "pipe"],
|
|
@@ -699,7 +1105,16 @@ export async function executePhaseWithRetry(issueNumber, phase, config, resumeHa
|
|
|
699
1105
|
/** @internal Injected for testing — defaults to module-level executePhase */
|
|
700
1106
|
executePhaseFn = executePhase,
|
|
701
1107
|
/** @internal Injected for testing — defaults to setTimeout-based delay */
|
|
702
|
-
delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
1108
|
+
delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
1109
|
+
/**
|
|
1110
|
+
* Per-issue auto-wait accounting (#804). Deliberately the LAST parameter:
|
|
1111
|
+
* the #761/#799 regression tests call this function positionally with
|
|
1112
|
+
* `executePhaseFn` at 8 and `delayFn` at 9, and AC-2 requires those tests to
|
|
1113
|
+
* pass unmodified. Defaults to a fresh disabled-or-config-derived ledger, so
|
|
1114
|
+
* a caller that does not thread one still gets correct (bounded) behavior —
|
|
1115
|
+
* just scoped to this phase rather than the issue.
|
|
1116
|
+
*/
|
|
1117
|
+
autoWaitLedger = createAutoWaitLedger(config.autoWaitMinutes)) {
|
|
703
1118
|
// Skip retry logic if explicitly disabled
|
|
704
1119
|
if (config.retry === false) {
|
|
705
1120
|
return executePhaseFn(issueNumber, phase, config, resumeHandle, worktreePath, shutdownManager, spinner);
|
|
@@ -715,18 +1130,32 @@ delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
|
|
|
715
1130
|
phaseRegistry.get(phase).retryStrategy?.maxRetries === 0;
|
|
716
1131
|
let lastResult;
|
|
717
1132
|
if (skipColdStartRetry) {
|
|
718
|
-
// Single attempt — no cold-start retry loop
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
1133
|
+
// Single attempt — no cold-start retry loop. The only thing that re-enters
|
|
1134
|
+
// this loop is a granted auto-wait (#804): `maxRetries: 0` means "a retry
|
|
1135
|
+
// cannot help", but a reopened rate-limit window is precisely the case
|
|
1136
|
+
// where it can. Bounded by AUTO_WAIT_MAX_WAITS.
|
|
1137
|
+
for (;;) {
|
|
1138
|
+
lastResult = await executePhaseFn(issueNumber, phase, config, resumeHandle, worktreePath, shutdownManager, spinner);
|
|
1139
|
+
if (lastResult.success) {
|
|
1140
|
+
return lastResult;
|
|
1141
|
+
}
|
|
1142
|
+
// Turn-capped phase (#739): incomplete-but-not-hard-failed. A retry cannot
|
|
1143
|
+
// un-cap a turn limit, so short-circuit before any fallback — same rationale
|
|
1144
|
+
// as the billing skip (#732), but capped must skip *all* retries (incl.
|
|
1145
|
+
// cold-start), so an explicit early return is required, not just a guard
|
|
1146
|
+
// flag at the MCP gate.
|
|
1147
|
+
if (lastResult.capped) {
|
|
1148
|
+
return lastResult;
|
|
1149
|
+
}
|
|
1150
|
+
// #804: without this, `--auto-wait` would silently not cover the `loop`
|
|
1151
|
+
// phase — the one phase registered with `maxRetries: 0` — and a window
|
|
1152
|
+
// limit hitting /loop would still hard-halt with the flag set.
|
|
1153
|
+
const decision = shouldAutoWaitForReset(lastResult.structuredError, autoWaitLedger);
|
|
1154
|
+
if (!decision)
|
|
1155
|
+
break;
|
|
1156
|
+
const { aborted } = await performAutoWait(issueNumber, phase, config, decision, autoWaitLedger, delayFn, shutdownManager, spinner);
|
|
1157
|
+
if (aborted)
|
|
1158
|
+
return lastResult;
|
|
730
1159
|
}
|
|
731
1160
|
}
|
|
732
1161
|
else {
|
|
@@ -746,6 +1175,49 @@ delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
|
|
|
746
1175
|
if (lastResult.capped) {
|
|
747
1176
|
return lastResult;
|
|
748
1177
|
}
|
|
1178
|
+
// Window-exhausted rate limit (#761 AC-2): the reset is hours away, so
|
|
1179
|
+
// every retry re-spawns into the same closed window — worst case
|
|
1180
|
+
// ~4 × phaseTimeout (≈2h) of doomed attempts before the run halts.
|
|
1181
|
+
// Modelled on the `capped` early return above: skip all remaining
|
|
1182
|
+
// cold-start retries and (via the return) the MCP fallback. Checked
|
|
1183
|
+
// before the duration branch because a rate-limit rejection typically
|
|
1184
|
+
// fails fast and would otherwise be mistaken for a cold-start failure.
|
|
1185
|
+
if (isWindowExhaustedRateLimit(lastResult.structuredError)) {
|
|
1186
|
+
// #804: opt-in auto-wait. Sits between phase attempts (never inside a
|
|
1187
|
+
// spawn), so `phaseTimeout` is not implicated. Returns null — and this
|
|
1188
|
+
// falls through to today's halt — whenever auto-wait is off, the
|
|
1189
|
+
// budget/bound is spent, or the failure is billing rather than a rate
|
|
1190
|
+
// limit.
|
|
1191
|
+
const decision = shouldAutoWaitForReset(lastResult.structuredError, autoWaitLedger);
|
|
1192
|
+
if (decision) {
|
|
1193
|
+
const { aborted } = await performAutoWait(issueNumber, phase, config, decision, autoWaitLedger, delayFn, shutdownManager, spinner);
|
|
1194
|
+
if (!aborted) {
|
|
1195
|
+
// A wait is not a cold-start retry, so it must not consume one:
|
|
1196
|
+
// the loop's `attempt++` cancels this out. Bounded by
|
|
1197
|
+
// AUTO_WAIT_MAX_WAITS, which is what makes this safe from looping.
|
|
1198
|
+
attempt--;
|
|
1199
|
+
continue;
|
|
1200
|
+
}
|
|
1201
|
+
return lastResult;
|
|
1202
|
+
}
|
|
1203
|
+
if (config.verbose) {
|
|
1204
|
+
bracketedConsoleLog(spinner, chalk.yellow(`\n ✕ ${lastResult.error ?? "Rate limited"} — window exhausted, skipping retries`));
|
|
1205
|
+
}
|
|
1206
|
+
return lastResult;
|
|
1207
|
+
}
|
|
1208
|
+
// Transient rate limit (#761 AC-4): retry, but with real backoff — the
|
|
1209
|
+
// bare `continue` this replaces re-spawned immediately into the same
|
|
1210
|
+
// throttle. Reuses the injected `delayFn`; delay doubles per attempt.
|
|
1211
|
+
// Metadata-absent rate limits land here by design (AC-9 fallback rule).
|
|
1212
|
+
if (lastResult.structuredError instanceof RateLimitError &&
|
|
1213
|
+
attempt < COLD_START_MAX_RETRIES) {
|
|
1214
|
+
const backoffMs = RATE_LIMIT_RETRY_BACKOFF_MS * 2 ** attempt;
|
|
1215
|
+
if (config.verbose) {
|
|
1216
|
+
bracketedConsoleLog(spinner, chalk.yellow(`\n ⟳ ${lastResult.error ?? "Rate limited"} — backing off ${backoffMs}ms before retry... (attempt ${attempt + 2}/${COLD_START_MAX_RETRIES + 1})`));
|
|
1217
|
+
}
|
|
1218
|
+
await delayFn(backoffMs);
|
|
1219
|
+
continue;
|
|
1220
|
+
}
|
|
749
1221
|
// Genuine failure (took long enough to be real work) → skip cold-start retries.
|
|
750
1222
|
// Use error classification (AC-9): if the error is retryable (e.g., API
|
|
751
1223
|
// rate limit, transient 503), allow one more attempt even for genuine failures.
|
|
@@ -793,11 +1265,16 @@ delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
|
|
|
793
1265
|
// intent is documented and future code paths can't accidentally re-spawn a
|
|
794
1266
|
// capped phase without MCP.
|
|
795
1267
|
const failureIsCapped = lastResult.capped === true;
|
|
1268
|
+
// A throttle must not trigger "retrying without MCP" (#761 AC-3): MCP was
|
|
1269
|
+
// never the cause, and the re-spawn burns up to another full phaseTimeout
|
|
1270
|
+
// against the same limit while mislabeling the failure as MCP-related.
|
|
1271
|
+
const failureIsRateLimited = lastResult.structuredError instanceof RateLimitError;
|
|
796
1272
|
if (config.mcp &&
|
|
797
1273
|
!lastResult.success &&
|
|
798
1274
|
!skipColdStartRetry &&
|
|
799
1275
|
!failureIsBilling &&
|
|
800
|
-
!failureIsCapped
|
|
1276
|
+
!failureIsCapped &&
|
|
1277
|
+
!failureIsRateLimited) {
|
|
801
1278
|
bracketedConsoleLog(spinner, chalk.yellow(`\n ! Phase failed with MCP enabled, retrying without MCP...`));
|
|
802
1279
|
// Create config copy with MCP disabled
|
|
803
1280
|
const configWithoutMcp = {
|