Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,18 @@ Monitored repositories are auto-discovered from the GitHub App installations.
| `AUTOFIX_CLAUDE_MODEL` | No | CLI default | Claude model to use |
| `AUTOFIX_LOG_LEVEL` | No | `info` | Log level (debug/info/warn/error) |

### Retry policy

When a fix attempt fails, the failure is classified before Fixooly decides whether to try again:

| Class | Examples | Behavior |
|---|---|---|
| Permanent | Push rejected by branch protection, PAT missing the `workflow` scope, write permission denied | Given up on immediately — a retry would fail identically |
| Transient | Claude usage limit, expired/invalid auth, network errors | Retried after a backoff, without consuming a retry attempt |
| Other | Fix generation errored or timed out | Retried after a backoff, consuming one of 3 attempts |

Retries back off (5 → 15 → 30 minutes) instead of running every cycle, so a failing bug cannot burn a full fix generation every couple of minutes. Once a bug is given up on, Fixooly posts a comment on the PR explaining why and never picks it up again.

### Claude Authentication

| Variable | Required | Description |
Expand Down
34 changes: 26 additions & 8 deletions src/bugbotMonitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,16 @@
import { isBugbotComment, parseBugbotComment } from "./bugParser.js";
import type { GitHubClient } from "./githubClient.js";
import { logger } from "./logger.js";
import { BUG_STATUS } from "./state.js";
import type { StateStore } from "./state.js";
import type { Config, PrBugReport } from "./types.js";

const DEFAULT_LOOKBACK_DAYS = 7;
// Widened window used while a repo still has retryable bugs, so a bug whose
// comment has aged out of the default window can still be re-found. Bounded
// on purpose: dropping the filter entirely paginates every review comment in
// the repo on every cycle.
const RETRY_LOOKBACK_DAYS = 30;

export class BugbotMonitor {
private github: GitHubClient;
Expand Down Expand Up @@ -110,7 +116,8 @@ export class BugbotMonitor {
return null;
}

// First pass: filter out already-processed bugs (cheap local check)
// First pass: filter out already-processed and backed-off bugs
// (cheap local checks, no API calls)
const candidateComments = [];
for (const comment of bugbotComments) {
const bug = parseBugbotComment(comment);
Expand All @@ -119,6 +126,12 @@ export class BugbotMonitor {
logger.debug("Bug already processed, skipping.", { bugId: bug.bugId });
continue;
}
if (this.state.isInRetryBackoff(bug.bugId)) {
logger.debug("Bug is in retry backoff, skipping this cycle.", {
bugId: bug.bugId,
});
continue;
}
candidateComments.push({ comment, bug });
}

Expand All @@ -137,7 +150,7 @@ export class BugbotMonitor {
repo: `${owner}/${repo}`,
prNumber,
})),
"SKIPPED_PR_CLOSED"
BUG_STATUS.SKIPPED_PR_CLOSED
);
logger.debug(
`PR #${prNumber} in ${owner}/${repo} is closed/inaccessible, skipping ${candidateComments.length} bug(s).`,
Expand Down Expand Up @@ -177,7 +190,7 @@ export class BugbotMonitor {
repo: `${owner}/${repo}`,
prNumber,
})),
"SKIPPED_RESOLVED"
BUG_STATUS.SKIPPED_RESOLVED
);
logger.debug(
`Marked ${resolvedBugs.length} resolved bug(s) as SKIPPED_RESOLVED.`,
Expand Down Expand Up @@ -228,12 +241,17 @@ export class BugbotMonitor {
// Compute the "since" timestamp for the API query
// ============================================================

private computeSinceForRepo(repo: string): string | undefined {
if (this.state.hasRetryableBugsForRepo(repo)) {
logger.debug("Skipping since filter to retry failed/skipped bugs.", { repo });
return undefined;
private computeSinceForRepo(repo: string): string {
const hasRetryable = this.state.hasRetryableBugsForRepo(repo);
if (hasRetryable) {
logger.debug("Widening since filter to retry failed bugs.", {
repo,
lookbackDays: RETRY_LOOKBACK_DAYS,
});
}
const lookbackMs = DEFAULT_LOOKBACK_DAYS * 24 * 60 * 60 * 1000;

const days = hasRetryable ? RETRY_LOOKBACK_DAYS : DEFAULT_LOOKBACK_DAYS;
const lookbackMs = days * 24 * 60 * 60 * 1000;
Comment thread
cursor[bot] marked this conversation as resolved.
return new Date(Date.now() - lookbackMs).toISOString();
}
}
71 changes: 71 additions & 0 deletions src/failureClassifier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Classifies fix-generation failures so the daemon can decide whether
// retrying is worth another full fix generation.
// permanent — the same run will fail the same way (push rejected by a
// branch rule, missing token scope). Give up immediately.
// transient — an outage unrelated to the bug (usage limit, expired auth,
// network). Retry later without spending a retry attempt.
// retryable — anything else. Retry, but count it against the cap.

export type FailureKind = "permanent" | "transient" | "retryable";

export interface FailureClassification {
kind: FailureKind;
// Short human-readable cause, used in logs and the PR comment.
reason: string;
}

const PERMANENT_PATTERNS: Array<{ pattern: RegExp; reason: string }> = [
{
pattern: /without .?workflow.? scope/i,
reason: "The push token lacks the `workflow` scope required to update .github/workflows files.",
},
{
pattern: /protected branch|GH006|pre-receive hook declined/i,
reason: "The PR head branch rejects pushes (branch protection or a pre-receive hook).",
},
{
pattern: /permission to .+ denied|403 Forbidden/i,
reason: "The push credentials are not allowed to write to this repository.",
},
{
pattern: /remote rejected|refusing to allow/i,
reason: "GitHub rejected the push to the PR head branch.",
},
];

const TRANSIENT_PATTERNS: Array<{ pattern: RegExp; reason: string }> = [
{
pattern: /hit your limit|usage limit|rate limit|\b429\b/i,
reason: "Claude usage limit reached.",
},
{
// Bad push credentials break every repo at once, so this must not be
// treated as a per-bug fault: it would abandon unrelated bugs and post
// give-up comments everywhere while the token is being rotated.
pattern:
/invalid username or token|authentication failed for|could not read Username/i,
reason: "The git push token is invalid or expired (AUTOFIX_PUSH_TOKEN).",
},
{
pattern:
/oauth (?:access )?(?:token|session) (?:has )?expired|invalid authentication credentials|\b401\b/i,
reason: "Claude authentication expired or was rejected.",
},
{
pattern:
/ECONNRESET|ETIMEDOUT|ENOTFOUND|EADDRNOTAVAIL|EAI_AGAIN|socket hang up|\b50[234]\b/i,
reason: "Network or upstream service error.",
},
];

export function classifyFailure(message: string): FailureClassification {
for (const { pattern, reason } of PERMANENT_PATTERNS) {
if (pattern.test(message)) return { kind: "permanent", reason };
}

for (const { pattern, reason } of TRANSIENT_PATTERNS) {
if (pattern.test(message)) return { kind: "transient", reason };
}

return { kind: "retryable", reason: "Fix generation failed." };
}
23 changes: 21 additions & 2 deletions src/fixGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,8 +386,13 @@ export class FixGenerator {
stderr: stderr.substring(0, 1000) || "(empty)",
stdoutTail: stdout.substring(Math.max(0, stdout.length - 2000)) || "(empty)",
});
// The actionable cause (usage limit, expired auth) is printed by the
// CLI rather than raised as an exit reason, so carry it in the error
// message — callers classify failures from that message alone.
reject(
new Error(`claude -p fix generation exited with code ${code}.`)
new Error(
`claude -p fix generation exited with code ${code}. ${extractFailureDetail(stdout, stderr)}`
)
);
return;
}
Expand Down Expand Up @@ -950,11 +955,25 @@ function parseFixDetails(claudeOutput: string): Map<string, string> {
return details;
}

// ============================================================
// Utility: pull the actionable tail out of a failed claude -p run
// ============================================================

const MAX_FAILURE_DETAIL_CHARS = 500;

function extractFailureDetail(stdout: string, stderr: string): string {
const source = stderr.trim() || stdout.trim();
if (!source) return "(no output)";
return source.length > MAX_FAILURE_DETAIL_CHARS
? source.slice(-MAX_FAILURE_DETAIL_CHARS)
: source;
}

// ============================================================
// Utility: strip leaked tokens from git error messages
// ============================================================

function sanitizeGitError(message: string): string {
export function sanitizeGitError(message: string): string {
return message
.replace(/x-access-token:[^\s@]+/g, "x-access-token:[REDACTED]")
.replace(/http\.[^\s]*\.extraheader=Authorization: basic [A-Za-z0-9+/=]+/g, "http.extraheader=[REDACTED]")
Expand Down
Loading