sequant 2.9.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 +14 -3
- package/dist/bin/cli.js +97 -17
- 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 +56 -2
- package/dist/marketplace/external_plugins/sequant/hooks/parallel-marker.sh +50 -0
- package/dist/marketplace/external_plugins/sequant/hooks/post-tool.sh +20 -7
- package/dist/marketplace/external_plugins/sequant/hooks/pre-tool.sh +75 -10
- package/dist/marketplace/external_plugins/sequant/skills/_shared/references/trust-model.md +18 -0
- package/dist/marketplace/external_plugins/sequant/skills/assess/SKILL.md +215 -390
- package/dist/marketplace/external_plugins/sequant/skills/assess/references/predicted-collision-detection.md +1 -1
- 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 +3 -1
- 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/release/SKILL.md +1 -1
- 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/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 +14 -5
- 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.js +37 -4
- 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/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/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 +62 -0
- package/dist/src/lib/errors.d.ts +78 -6
- package/dist/src/lib/errors.js +133 -15
- 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/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 +45 -2
- package/dist/src/lib/workflow/batch-executor.d.ts +125 -6
- package/dist/src/lib/workflow/batch-executor.js +477 -44
- package/dist/src/lib/workflow/chain-resume.d.ts +13 -11
- package/dist/src/lib/workflow/chain-resume.js +24 -20
- 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/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 +14 -0
- package/dist/src/lib/workflow/drivers/claude-code.js +20 -2
- 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 +75 -6
- package/dist/src/lib/workflow/metrics-schema.d.ts +3 -3
- package/dist/src/lib/workflow/phase-executor.d.ts +203 -14
- package/dist/src/lib/workflow/phase-executor.js +457 -52
- 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 +14 -0
- package/dist/src/lib/workflow/run-log-schema.js +47 -1
- package/dist/src/lib/workflow/run-orchestrator.d.ts +19 -0
- package/dist/src/lib/workflow/run-orchestrator.js +141 -20
- 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/types.d.ts +126 -4
- package/dist/src/lib/workflow/types.js +1 -0
- package/dist/src/lib/workflow/worktree-manager.d.ts +28 -2
- package/dist/src/lib/workflow/worktree-manager.js +102 -23
- package/dist/src/mcp/tools/run.d.ts +9 -1
- package/dist/src/mcp/tools/run.js +20 -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 +8 -7
- package/templates/hooks/parallel-marker.sh +50 -0
- package/templates/hooks/post-tool.sh +20 -7
- package/templates/hooks/pre-tool.sh +75 -10
- package/templates/scripts/cleanup-worktree.sh +182 -19
- package/templates/scripts/new-feature.sh +264 -16
- package/templates/skills/_shared/references/trust-model.md +18 -0
- package/templates/skills/assess/SKILL.md +215 -390
- package/templates/skills/assess/references/predicted-collision-detection.md +1 -1
- package/templates/skills/docs/SKILL.md +4 -4
- package/templates/skills/exec/SKILL.md +26 -6
- package/templates/skills/fullsolve/SKILL.md +3 -1
- 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/release/SKILL.md +1 -1
- package/templates/skills/setup/SKILL.md +5 -5
- package/templates/skills/spec/SKILL.md +2 -0
- 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, RateLimitError, resetsAtToMs, } 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
|
/**
|
|
@@ -136,6 +138,241 @@ export function isWindowExhaustedRateLimit(error, now = Date.now()) {
|
|
|
136
138
|
return false;
|
|
137
139
|
return resetsAtToMs(resetsAt) - now > RATE_LIMIT_WINDOW_SKIP_THRESHOLD_MS;
|
|
138
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
|
+
}
|
|
139
376
|
export function parseQaVerdict(output) {
|
|
140
377
|
if (!output)
|
|
141
378
|
return null;
|
|
@@ -160,6 +397,59 @@ export function parseQaVerdict(output) {
|
|
|
160
397
|
const verdict = verdictMatch[1].toUpperCase().replace(/-/g, "_");
|
|
161
398
|
return verdict;
|
|
162
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
|
+
}
|
|
163
453
|
/**
|
|
164
454
|
* Parse condensed QA summary from QA phase output (#434).
|
|
165
455
|
*
|
|
@@ -313,26 +603,41 @@ export function resolveBaseRef(cwd) {
|
|
|
313
603
|
return recorded.startsWith("origin/") ? recorded : `origin/${recorded}`;
|
|
314
604
|
}
|
|
315
605
|
/**
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
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).
|
|
319
623
|
*
|
|
320
|
-
* Uses `git rev-list --count <base>..HEAD` (commits reachable from HEAD
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
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.
|
|
325
629
|
*
|
|
326
630
|
* The base ref defaults to `origin/main` but is overridden to the worktree's
|
|
327
|
-
* recorded base (see #537) so zero-diff execs are still detected on
|
|
328
|
-
*
|
|
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`).
|
|
329
633
|
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
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).
|
|
332
637
|
*
|
|
333
638
|
* @internal Exported for testing only.
|
|
334
639
|
*/
|
|
335
|
-
export function
|
|
640
|
+
export function classifyExecChanges(cwd) {
|
|
336
641
|
const baseRef = resolveBaseRef(cwd);
|
|
337
642
|
let commitsAhead;
|
|
338
643
|
try {
|
|
@@ -342,23 +647,53 @@ export function hasExecChanges(cwd) {
|
|
|
342
647
|
commitsAhead = Number.parseInt(count, 10) > 0;
|
|
343
648
|
}
|
|
344
649
|
catch {
|
|
345
|
-
return
|
|
650
|
+
return { kind: "unknown" };
|
|
346
651
|
}
|
|
347
652
|
if (commitsAhead)
|
|
348
|
-
return
|
|
653
|
+
return { kind: "commits" };
|
|
349
654
|
try {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
})
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
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" };
|
|
357
661
|
}
|
|
358
662
|
catch {
|
|
359
|
-
return
|
|
663
|
+
return { kind: "unknown" };
|
|
360
664
|
}
|
|
361
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
|
+
}
|
|
362
697
|
/**
|
|
363
698
|
* Map a successful AgentPhaseResult to a PhaseResult, applying phase-specific
|
|
364
699
|
* guards that catch agent sessions which returned success without producing
|
|
@@ -405,12 +740,20 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
|
|
|
405
740
|
};
|
|
406
741
|
}
|
|
407
742
|
if (!verdict) {
|
|
408
|
-
// #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";
|
|
409
752
|
return {
|
|
410
753
|
phase,
|
|
411
754
|
success: false,
|
|
412
755
|
durationSeconds,
|
|
413
|
-
error
|
|
756
|
+
error,
|
|
414
757
|
...resume,
|
|
415
758
|
output: agentResult.output,
|
|
416
759
|
summary,
|
|
@@ -428,17 +771,38 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
|
|
|
428
771
|
...tails,
|
|
429
772
|
};
|
|
430
773
|
}
|
|
431
|
-
if (phase === "exec"
|
|
432
|
-
// #534: an exec phase that produced
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
+
}
|
|
442
806
|
}
|
|
443
807
|
return {
|
|
444
808
|
phase,
|
|
@@ -548,7 +912,8 @@ async function executePhase(issueNumber, phase, config, resumeHandle, worktreePa
|
|
|
548
912
|
let files;
|
|
549
913
|
if (config.agent && config.agent !== "claude-code") {
|
|
550
914
|
try {
|
|
551
|
-
const
|
|
915
|
+
const diffBase = resolveDiffBase(cwd, "main");
|
|
916
|
+
const output = execFileSync("git", ["diff", "--name-only", `${diffBase}...HEAD`], {
|
|
552
917
|
cwd,
|
|
553
918
|
encoding: "utf-8",
|
|
554
919
|
stdio: ["pipe", "pipe", "pipe"],
|
|
@@ -740,7 +1105,16 @@ export async function executePhaseWithRetry(issueNumber, phase, config, resumeHa
|
|
|
740
1105
|
/** @internal Injected for testing — defaults to module-level executePhase */
|
|
741
1106
|
executePhaseFn = executePhase,
|
|
742
1107
|
/** @internal Injected for testing — defaults to setTimeout-based delay */
|
|
743
|
-
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)) {
|
|
744
1118
|
// Skip retry logic if explicitly disabled
|
|
745
1119
|
if (config.retry === false) {
|
|
746
1120
|
return executePhaseFn(issueNumber, phase, config, resumeHandle, worktreePath, shutdownManager, spinner);
|
|
@@ -756,18 +1130,32 @@ delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
|
|
|
756
1130
|
phaseRegistry.get(phase).retryStrategy?.maxRetries === 0;
|
|
757
1131
|
let lastResult;
|
|
758
1132
|
if (skipColdStartRetry) {
|
|
759
|
-
// Single attempt — no cold-start retry loop
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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;
|
|
771
1159
|
}
|
|
772
1160
|
}
|
|
773
1161
|
else {
|
|
@@ -795,6 +1183,23 @@ delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
|
|
|
795
1183
|
// before the duration branch because a rate-limit rejection typically
|
|
796
1184
|
// fails fast and would otherwise be mistaken for a cold-start failure.
|
|
797
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
|
+
}
|
|
798
1203
|
if (config.verbose) {
|
|
799
1204
|
bracketedConsoleLog(spinner, chalk.yellow(`\n ✕ ${lastResult.error ?? "Rate limited"} — window exhausted, skipping retries`));
|
|
800
1205
|
}
|
|
@@ -190,13 +190,14 @@ Run relevant commands and capture their output for review.`,
|
|
|
190
190
|
phaseRegistry.register({
|
|
191
191
|
name: "qa",
|
|
192
192
|
skill: "qa",
|
|
193
|
-
promptTemplate: "Review the implementation for GitHub issue #{issue} against acceptance criteria. Run the /qa {issue} workflow.",
|
|
193
|
+
promptTemplate: "Review the implementation for GitHub issue #{issue} against acceptance criteria. Run the /qa {issue} workflow. Your turn is one-shot: you must emit a `### Verdict:` line before the turn ends — deferring to a later turn or a background CI poll is not available.",
|
|
194
194
|
requiresWorktree: true,
|
|
195
195
|
driverOverrides: {
|
|
196
196
|
aider: {
|
|
197
197
|
promptTemplate: `Review the changes for GitHub issue #{issue}.
|
|
198
198
|
Run 'npm test' and 'npm run build' to verify everything works.
|
|
199
199
|
Check each acceptance criterion from the issue comments.
|
|
200
|
+
Your turn is one-shot: emit a verdict now — do not defer to a later turn or a background CI poll. If the tree is unreviewable, emit AC_NOT_MET (code-caused red CI) or NEEDS_VERIFICATION (pending/infra-blocked CI); never end without a verdict.
|
|
200
201
|
Output a verdict: READY_FOR_MERGE, AC_MET_BUT_NOT_A_PLUS, or AC_NOT_MET
|
|
201
202
|
with format "### Verdict: <VERDICT>" followed by an explanation.`,
|
|
202
203
|
},
|