fix(analyzer): rate limiting, tree reset, and partial persistence#4
Conversation
…sistence Three real failures surfaced in the first end-to-end run against v3.17.0 -> v3.17.4 (116 commits). Addressing all three: 1. Rate limiting on pass 1 Copilot Student / Pro tier caps low-tier models at 15 req/min. The first run blew through the limit at request #15 and returned instant 429 fallbacks ("NEEDS_REVIEW" with no reasoning) for commits 16-116. Add a RateLimiter (requests-per-minute, simple interval throttle) and wire it through classifyCommitsSequentially. Default 12 rpm stays comfortably under the ceiling. 2. Dirty working tree blocked batch-builder checkout `bun install` without --frozen-lockfile modifies bun.lock, then createBranchFromTag calls `git checkout -b` which refuses to proceed with: error: Your local changes to the following files would be overwritten by checkout: bun.lock Add resetWorkingTree() (git reset --hard HEAD + git clean -fdx except .analyzer-output) and call it before batch-building. Safe because the CI clone is ephemeral and never pushed. 3. Crashes lost all partial progress When pass 1 or later steps failed, the workflow uploaded nothing useful since artifacts were only written at the end. Rewrite runPipeline to writeArtifact after each pass: classifications.initial.json (after pass 1) verifications.json + classifications.json (after pass 2) synthesis.json (after pass 3) batches.json (after batch-builder) pipeline-result.json (combined, at the end) Combined with `if: always()` on the upload step, this guarantees post-mortem data even on crash. classifyCommitsSequentially signature changed from positional to an options object to make the new requestsPerMinute parameter optional without threading boilerplate through callers.
🤖 Augment PR SummarySummary: Improves the upstream analyzer’s reliability by adding throttling, ensuring a clean git workspace before batch building, and persisting intermediate outputs throughout the pipeline. Changes:
Technical Notes: Artifact persistence now happens incrementally, and batch building is preceded by a hard reset/clean to avoid checkout conflicts in CI. 🤖 Was this summary useful? React with 👍 or 👎 |
| const classification = await classifyCommit(options.commits[i], options.systemPrompt, options.model) | ||
| results.push(classification) | ||
| onProgress?.(i + 1, commits.length, classification) | ||
| options.onProgress?.(i + 1, options.commits.length, classification) |
There was a problem hiding this comment.
ClassifySequentiallyOptions.onProgress is typed as returning void, but callers can still pass an async function and the returned Promise is currently not awaited/handled here, which can lead to unhandled rejections or out-of-order progress reporting. Consider either enforcing a sync callback or explicitly supporting Promise<void> and awaiting it.
Severity: medium
Other Locations
script/upstream-analyzer/pipeline.ts:57
🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.
| // --hard HEAD: throw away tracked file changes. -fdx: remove untracked | ||
| // files and ignored files (node_modules, .analyzer-output, etc). | ||
| await $`git reset --hard HEAD`.quiet().nothrow() | ||
| await $`git clean -fdx -e .analyzer-output`.quiet().nothrow() |
There was a problem hiding this comment.
git clean -fdx -e .analyzer-output hard-codes the preserved directory name, but outputDir is configurable via OUTPUT_DIR, so a non-default output directory would be deleted during resetWorkingTree() (undoing the new partial persistence). Consider ensuring the exclusion matches the configured outputDir.
Severity: high
Other Locations
script/upstream-analyzer/pipeline.ts:104script/upstream-analyzer/cli-config.ts:50
🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.
There was a problem hiding this comment.
Pull request overview
Improves the upstream analyzer pipeline’s resilience during long runs by adding request throttling, ensuring a clean git workspace before batch branch creation, and persisting intermediate artifacts so partial progress is preserved on failure.
Changes:
- Add a
RateLimiterand apply it to pass-1 commit classification to avoid GitHub Models 429s. - Reset/clean the working tree before batch building to prevent checkout failures from lockfile changes.
- Write analyzer artifacts after each pass (and at end) to preserve partial results for post-mortems.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| script/upstream-analyzer/throttle.ts | Introduces a simple interval-based rate limiter. |
| script/upstream-analyzer/commit-classifier.ts | Switches sequential classification to an options object and throttles requests. |
| script/upstream-analyzer/git-inspector.ts | Adds resetWorkingTree() to hard reset + clean before batch building. |
| script/upstream-analyzer/pipeline.ts | Uses the new classifier API, resets working tree before batches, and writes artifacts after each pass. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| commits, | ||
| systemPrompt, | ||
| model: config.modelClassify, | ||
| onProgress: async (i, n, classification) => { |
There was a problem hiding this comment.
onProgress is declared async here, but classifyCommitsSequentially does not await the callback. That makes it easy to accidentally add awaited work later (e.g., file writes) that will run concurrently/out of order and can hide errors. Either remove async on this callback (since it’s just logging) or change onProgress to return Promise<void> and await it in the classifier.
| onProgress: async (i, n, classification) => { | |
| onProgress: (i, n, classification) => { |
| export async function resetWorkingTree(): Promise<void> { | ||
| // `bun install` without --frozen-lockfile may modify bun.lock, which | ||
| // blocks subsequent `git checkout -b` with "local changes would be | ||
| // overwritten". Reset discards every uncommitted modification in the | ||
| // ephemeral CI clone so the batch-build phase starts from a clean slate. | ||
| // --hard HEAD: throw away tracked file changes. -fdx: remove untracked | ||
| // files and ignored files (node_modules, .analyzer-output, etc). | ||
| await $`git reset --hard HEAD`.quiet().nothrow() | ||
| await $`git clean -fdx -e .analyzer-output`.quiet().nothrow() |
There was a problem hiding this comment.
resetWorkingTree hard-codes -e .analyzer-output, but the pipeline’s outputDir is configurable via env (OUTPUT_DIR). If a non-default outputDir is used, git clean -fdx can delete the directory and wipe the artifacts you just started persisting. Consider passing the configured outputDir into resetWorkingTree (or accepting an array of excludes) and excluding that path instead of a constant.
| export async function resetWorkingTree(): Promise<void> { | |
| // `bun install` without --frozen-lockfile may modify bun.lock, which | |
| // blocks subsequent `git checkout -b` with "local changes would be | |
| // overwritten". Reset discards every uncommitted modification in the | |
| // ephemeral CI clone so the batch-build phase starts from a clean slate. | |
| // --hard HEAD: throw away tracked file changes. -fdx: remove untracked | |
| // files and ignored files (node_modules, .analyzer-output, etc). | |
| await $`git reset --hard HEAD`.quiet().nothrow() | |
| await $`git clean -fdx -e .analyzer-output`.quiet().nothrow() | |
| export async function resetWorkingTree(outputDir: string = ".analyzer-output"): Promise<void> { | |
| // `bun install` without --frozen-lockfile may modify bun.lock, which | |
| // blocks subsequent `git checkout -b` with "local changes would be | |
| // overwritten". Reset discards every uncommitted modification in the | |
| // ephemeral CI clone so the batch-build phase starts from a clean slate. | |
| // --hard HEAD: throw away tracked file changes. -fdx: remove untracked | |
| // files and ignored files while preserving the configured analyzer | |
| // output directory. | |
| await $`git reset --hard HEAD`.quiet().nothrow() | |
| await $`git clean -fdx -e ${outputDir}`.quiet().nothrow() |
| await $`git reset --hard HEAD`.quiet().nothrow() | ||
| await $`git clean -fdx -e .analyzer-output`.quiet().nothrow() |
There was a problem hiding this comment.
Both git reset and git clean are executed with .nothrow(), so a failure to reset/clean (e.g., not a git repo, permissions issues) will be silently ignored and the later checkout/cherry-pick errors will be harder to diagnose. It would be safer to let these commands throw, or explicitly check exitCode and throw a targeted error when non-zero.
| await $`git reset --hard HEAD`.quiet().nothrow() | |
| await $`git clean -fdx -e .analyzer-output`.quiet().nothrow() | |
| await $`git reset --hard HEAD`.quiet() | |
| await $`git clean -fdx -e .analyzer-output`.quiet() |
| // --hard HEAD: throw away tracked file changes. -fdx: remove untracked | ||
| // files and ignored files (node_modules, .analyzer-output, etc). |
There was a problem hiding this comment.
The comment says -fdx removes ignored files including .analyzer-output, but the command excludes .analyzer-output via -e .analyzer-output. Consider updating the comment to reflect that the analyzer output directory is intentionally preserved while other ignored/untracked files (e.g., node_modules) are removed.
| // --hard HEAD: throw away tracked file changes. -fdx: remove untracked | |
| // files and ignored files (node_modules, .analyzer-output, etc). | |
| // --hard HEAD: throw away tracked file changes. `git clean -fdx` | |
| // removes other untracked and ignored files (for example, node_modules), | |
| // while `-e .analyzer-output` intentionally preserves analyzer output. |
| // --hard HEAD: throw away tracked file changes. -fdx: remove untracked | ||
| // files and ignored files (node_modules, .analyzer-output, etc). | ||
| await $`git reset --hard HEAD`.quiet().nothrow() | ||
| await $`git clean -fdx -e .analyzer-output`.quiet().nothrow() |
There was a problem hiding this comment.
🔴 Hardcoded git-clean exclusion path doesn't match configurable outputDir, deleting progressive artifacts
resetWorkingTree() at script/upstream-analyzer/git-inspector.ts:59 hardcodes -e .analyzer-output, but the output directory is configurable via the OUTPUT_DIR env var (script/upstream-analyzer/cli-config.ts:50). When OUTPUT_DIR is set to anything other than .analyzer-output, the git clean -fdx in resetWorkingTree() will delete all artifacts written before it runs. In pipeline.ts, four artifact files are written before resetWorkingTree() is called (lines 152, 159, 160, 163), and then resetWorkingTree() is invoked inside runBatchBuilding (line 104). This silently destroys classifications.initial.json, verifications.json, classifications.json, and synthesis.json — defeating the stated purpose of writing artifacts progressively so that "partial progress survives any downstream failure."
Prompt for agents
The `resetWorkingTree` function hardcodes the git-clean exclusion path as `.analyzer-output`, but the actual output directory is configurable via `AnalyzerConfig.outputDir` (set from the `OUTPUT_DIR` env var). When the two differ, `git clean -fdx` deletes all previously-written artifacts.
To fix this, `resetWorkingTree` should accept the output directory as a parameter and use it in the `-e` flag. The call site in `pipeline.ts:runBatchBuilding` (line 104) should pass `config.outputDir` (or receive it as a parameter). For example:
- In git-inspector.ts, change `resetWorkingTree()` to `resetWorkingTree(excludePaths: string[])` and build the `-e` flags dynamically.
- In pipeline.ts `runBatchBuilding`, pass `config.outputDir` so it gets excluded from the clean.
Was this helpful? React with 👍 or 👎 to provide feedback.
| onProgress: async (i, n, classification) => { | ||
| logProgress("pass1", `${i}/${n} ${classification.shortSha} => ${classification.verdict}`) | ||
| }, |
There was a problem hiding this comment.
📝 Info: Async onProgress callback's returned Promise is silently dropped
In pipeline.ts:57, the onProgress callback is declared async, but the type in commit-classifier.ts:97 specifies => void (not => Promise<void>). The call site at commit-classifier.ts:111 does not await the result. This is not a bug today because the callback body (logProgress) is synchronous — the async keyword just wraps a resolved promise that's immediately GC'd. However, if someone later adds real async work (e.g., writing progress to a file) inside this callback, the returned promise would be silently dropped, potentially causing unhandled rejections or lost operations. Removing the unnecessary async keyword or changing the type to Promise<void> and await-ing the call would be defensive.
Was this helpful? React with 👍 or 👎 to provide feedback.
| async throttle(): Promise<void> { | ||
| const now = Date.now() | ||
| const waitMs = this.nextAllowedAt - now | ||
| if (waitMs > 0) { | ||
| await new Promise((resolve) => setTimeout(resolve, waitMs)) | ||
| } | ||
| this.nextAllowedAt = Date.now() + this.minIntervalMs | ||
| } |
There was a problem hiding this comment.
📝 Info: RateLimiter uses simple interval spacing rather than sliding window
The RateLimiter in throttle.ts enforces a minimum interval between requests (e.g., 5000ms for 12 req/min) rather than tracking a sliding window of timestamps. This is a simpler and more conservative approach — it can never exceed the limit but may underutilize capacity after pauses (e.g., if a request takes 10 seconds, the next request still waits the full interval from completion). For this sequential-only use case this is perfectly fine, but worth noting if someone considers reusing this class for concurrent workloads.
Was this helpful? React with 👍 or 👎 to provide feedback.
| export async function resetWorkingTree(): Promise<void> { | ||
| // `bun install` without --frozen-lockfile may modify bun.lock, which | ||
| // blocks subsequent `git checkout -b` with "local changes would be | ||
| // overwritten". Reset discards every uncommitted modification in the | ||
| // ephemeral CI clone so the batch-build phase starts from a clean slate. | ||
| // --hard HEAD: throw away tracked file changes. -fdx: remove untracked | ||
| // files and ignored files (node_modules, .analyzer-output, etc). | ||
| await $`git reset --hard HEAD`.quiet().nothrow() | ||
| await $`git clean -fdx -e .analyzer-output`.quiet().nothrow() | ||
| } |
There was a problem hiding this comment.
📝 Info: resetWorkingTree removes node_modules with -fdx, subsequent batch building still works
The git clean -fdx at git-inspector.ts:59 removes node_modules (and all other untracked/ignored files). This is intentional per the comment — the CI clone is ephemeral. However, buildAllBatches (called right after) only uses git operations (createBranchFromTag, cherryPickCommit) and doesn't need node_modules, so this is fine. But if the batch building phase were ever extended to require bun commands or imports from node_modules, this would break.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3c6941e09f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // ephemeral CI clone so the batch-build phase starts from a clean slate. | ||
| // --hard HEAD: throw away tracked file changes. -fdx: remove untracked | ||
| // files and ignored files (node_modules, .analyzer-output, etc). | ||
| await $`git reset --hard HEAD`.quiet().nothrow() |
There was a problem hiding this comment.
Guard destructive reset to CI-only execution
resetWorkingTree() now hard-resets and cleans the repository on every analyzer run, but there is no runtime guard that limits this behavior to ephemeral CI clones. Since script/upstream-analyzer/run.ts is a normal executable entrypoint, invoking it from a local checkout with uncommitted work will irreversibly discard tracked edits, which is a high-impact regression in safety.
Useful? React with 👍 / 👎.
| // --hard HEAD: throw away tracked file changes. -fdx: remove untracked | ||
| // files and ignored files (node_modules, .analyzer-output, etc). | ||
| await $`git reset --hard HEAD`.quiet().nothrow() | ||
| await $`git clean -fdx -e .analyzer-output`.quiet().nothrow() |
There was a problem hiding this comment.
Exclude configured output directory from git clean
The clean command hard-codes -e .analyzer-output even though OUTPUT_DIR is configurable in loadConfigFromEnv(). When OUTPUT_DIR points to another in-repo path, the new pre-batch git clean -fdx deletes pass-1/2/3 artifacts written earlier in runPipeline(), defeating partial-progress persistence for that configuration.
Useful? React with 👍 / 👎.
Code Review SummaryStatus: No New Issues Found | Recommendation: Merge OverviewThe PR addresses three analyzer failures with well-structured fixes:
Existing Review Coverage
All significant issues in the diff have already been identified by other reviewers. Files Reviewed (4 files)
The existing review comments correctly identify the issues that should be addressed before merge. No additional issues found in the diff. Reviewed by minimax-m2.5-20260211 · 542,454 tokens |
Summary
Three real failures surfaced in the first end-to-end analyzer run (v3.17.0 → v3.17.4, 116 commits). Fixing all three.
1. Rate limiting on pass 1
Copilot Student / Pro tier caps low-tier models (`gpt-4.1-mini`) at 15 req/min. The first run blew through the limit at request #15 — commits 16-116 returned instant 429 fallbacks with no real classification:
```
pass1: 15/116 b0b19f3 => GOOD (1.5s, real inference)
pass1: 16/116 314e1a5 => NEEDS_REVIEW (0.1s, 429 fallback)
pass1: 17/116 d21ed3f => NEEDS_REVIEW (0.1s, 429 fallback)
... all remaining 100 commits: same 0.1s fallback pattern
```
Fix: new `RateLimiter` (`script/upstream-analyzer/throttle.ts`) with simple interval throttling. Default 12 rpm — comfortably below the 15 rpm ceiling.
2. Dirty working tree blocked batch-builder
`bun install` without `--frozen-lockfile` modifies `bun.lock`. Then `git checkout -b` refuses:
```
error: Your local changes to the following files would be overwritten by checkout:
bun.lock
Please commit your changes or stash them before you switch branches.
```
Fix: new `resetWorkingTree()` — `git reset --hard HEAD` + `git clean -fdx -e .analyzer-output`. Called before batch building. Safe because the CI clone is ephemeral and never pushed. `.analyzer-output` excluded so pass artifacts survive.
3. Crashes lost all partial progress
When the pipeline failed mid-run, artifact upload found no files (`.analyzer-output/` was empty — writes happened only at the end). Fix: `runPipeline` now `writeArtifact`s after each pass:
Combined with `if: always()` on the existing upload step, this guarantees post-mortem data even on crash.
API change
`classifyCommitsSequentially` signature changed from positional to options object — needed to add optional `requestsPerMinute` without threading boilerplate. Breaking only for this single internal call site, already updated.
Verification
Summary by cubic
Fixes three analyzer failures: avoids 429s in pass 1 with request throttling, prevents batch-building checkout errors by resetting the working tree, and saves artifacts after each pass so crashes keep partial results.
RateLimiter(12 rpm default) and updatedclassifyCommitsSequentiallyto an options signature to support rpm and progress callbacks.git reset --hard+git clean -fdx -e .analyzer-output) to avoid checkout failures.classifications.initial.json,verifications.json,classifications.json,synthesis.json,batches.json, thenpipeline-result.json) for reliable crash recovery.Written for commit 3c6941e. Summary will update on new commits.