Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
14 changes: 14 additions & 0 deletions .flake8
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[flake8]
max-line-length = 100
extend-ignore = E501,W503
exclude =
.git,
__pycache__,
.venv,
venv,
archive,
.extraction,
build,
dist,
Manager-Database,
Trend_Model_Project
246 changes: 210 additions & 36 deletions .github/scripts/keepalive_loop.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ const { formatFailureComment } = require('./failure_comment_formatter');
const { detectConflicts } = require('./conflict_detector');
const { parseTimeoutConfig } = require('./timeout_config');

// Token load balancer for rate limit management
let tokenLoadBalancer = null;
try {
tokenLoadBalancer = require('./token_load_balancer');
} catch (error) {
// Load balancer not available - will use fallback
}

const ATTEMPT_HISTORY_LIMIT = 5;
const ATTEMPTED_TASK_LIMIT = 6;

Expand Down Expand Up @@ -1390,6 +1398,125 @@ async function detectRateLimitCancellation({ github, context, runId, core }) {
return false;
}

/**
* Check API rate limit status before starting operations.
* Returns summary of available capacity across all tokens.
*
* @param {Object} options
* @param {Object} options.github - GitHub API client
* @param {Object} options.core - GitHub Actions core
* @param {number} options.minRequired - Minimum API calls needed (default: 50)
* @returns {Object} { canProceed, shouldDefer, totalRemaining, totalLimit, tokens, recommendation }
*/
async function checkRateLimitStatus({ github, core, minRequired = 50 }) {
// First check the current token's rate limit (always available)
let primaryRemaining = 5000;
let primaryLimit = 5000;
let primaryReset = null;

try {
const { data } = await github.rest.rateLimit.get();
primaryRemaining = data.resources.core.remaining;
primaryLimit = data.resources.core.limit;
primaryReset = data.resources.core.reset * 1000;
} catch (error) {
core?.warning?.(`Failed to check primary rate limit: ${error.message}`);
}

const primaryPercentUsed = primaryLimit > 0
? ((primaryLimit - primaryRemaining) / primaryLimit * 100).toFixed(1)
: 0;

const result = {
primary: {
remaining: primaryRemaining,
limit: primaryLimit,
percentUsed: parseFloat(primaryPercentUsed),
reset: primaryReset ? new Date(primaryReset).toISOString() : null,
},
tokens: [],
totalRemaining: primaryRemaining,
totalLimit: primaryLimit,
canProceed: primaryRemaining >= minRequired,
shouldDefer: false,
recommendation: 'proceed',
};

// If load balancer is available, check all tokens
if (tokenLoadBalancer) {
try {
const summary = tokenLoadBalancer.getRegistrySummary();
result.tokens = summary;

// Calculate totals across all token pools
let totalRemaining = 0;
let totalLimit = 0;
let healthyCount = 0;
let criticalCount = 0;

for (const token of summary) {
const remaining = typeof token.rateLimit?.remaining === 'number'
? token.rateLimit.remaining
: 0;
const limit = typeof token.rateLimit?.limit === 'number'
? token.rateLimit.limit
: 5000;

totalRemaining += remaining;
totalLimit += limit;

if (token.status === 'healthy' || token.status === 'moderate') {
healthyCount++;
} else if (token.status === 'critical') {
criticalCount++;
}
}

result.totalRemaining = totalRemaining || primaryRemaining;
result.totalLimit = totalLimit || primaryLimit;
result.healthyTokens = healthyCount;
result.criticalTokens = criticalCount;

// Determine if we should defer
result.shouldDefer = tokenLoadBalancer.shouldDefer(minRequired);
result.canProceed = !result.shouldDefer && result.totalRemaining >= minRequired;
Comment on lines +1480 to +1482
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Initialize token registry before deferring on limits

Because checkRateLimitStatus unconditionally calls tokenLoadBalancer.shouldDefer(minRequired) when the module is present, the keepalive loop will defer even if the primary token has quota whenever the registry is empty (the new module is required but initializeTokenRegistry is never called in this script). shouldDefer returns true when tokenRegistry.tokens is empty (token_load_balancer.js lines 631–637), so this path will always hit the early action: 'defer' return on every run unless forceRetry is set. This effectively stalls the loop in the default configuration.

Useful? React with 👍 / 👎.


// Calculate recommendation
if (result.shouldDefer) {
const minutesUntilReset = tokenLoadBalancer.getTimeUntilReset();
result.recommendation = minutesUntilReset
? `defer-${minutesUntilReset}m`
: 'defer-unknown';
} else if (result.totalRemaining < minRequired * 3) {
result.recommendation = 'proceed-with-caution';
} else {
result.recommendation = 'proceed';
}
} catch (error) {
core?.debug?.(`Load balancer check failed: ${error.message}`);
}
} else {
// Fallback: just use primary token status
result.shouldDefer = primaryRemaining < minRequired;
result.canProceed = primaryRemaining >= minRequired;

if (result.shouldDefer) {
const minutesUntilReset = primaryReset
? Math.max(0, Math.ceil((primaryReset - Date.now()) / 1000 / 60))
: null;
result.recommendation = minutesUntilReset
? `defer-${minutesUntilReset}m`
: 'defer-unknown';
}
}

// Log summary
core?.info?.(`Rate limit status: ${result.totalRemaining}/${result.totalLimit} remaining, ` +
`can proceed: ${result.canProceed}, recommendation: ${result.recommendation}`);

return result;
}

async function evaluateKeepaliveLoop({ github, context, core, payload: overridePayload, overridePrNumber, forceRetry }) {
const payload = overridePayload || context.payload || {};
const cache = getGithubApiCache({ github, core });
Expand All @@ -1402,6 +1529,26 @@ async function evaluateKeepaliveLoop({ github, context, core, payload: overrideP
repo: context?.repo?.repo,
});
}

// Check rate limit status early
let rateLimitStatus = null;
try {
rateLimitStatus = await checkRateLimitStatus({ github, core, minRequired: 50 });

// If all tokens are exhausted and we're not forcing retry, defer immediately
if (rateLimitStatus.shouldDefer && !forceRetry) {
core?.info?.(`Rate limits exhausted - deferring. Recommendation: ${rateLimitStatus.recommendation}`);
return {
prNumber: overridePrNumber || 0,
action: 'defer',
reason: 'rate-limit-exhausted',
rateLimitStatus,
};
}
} catch (error) {
core?.warning?.(`Rate limit check failed: ${error.message} - continuing anyway`);
}

try {
prNumber = overridePrNumber || await resolvePrNumber({ github, context, core, payload });
if (!prNumber) {
Expand Down Expand Up @@ -1653,6 +1800,8 @@ async function evaluateKeepaliveLoop({ github, context, core, payload: overrideP
// Progress review data for LLM-based alignment check
needsProgressReview,
roundsWithoutTaskCompletion,
// Rate limit status for monitoring
rateLimitStatus,
};
} catch (error) {
const rateLimitMessage = [error?.message, error?.response?.data?.message]
Expand Down Expand Up @@ -2343,46 +2492,60 @@ async function updateKeepaliveLoopSummary({ github, context, core, inputs }) {
summaryLines.push('', formatStateComment(newState));
const body = summaryLines.join('\n');

if (commentId) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: commentId,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body,
});
}

if (shouldEscalate) {
try {
await github.rest.issues.addLabels({
try {
if (commentId) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
labels: ['agent:needs-attention'],
comment_id: commentId,
body,
});
} catch (error) {
if (core) core.warning(`Failed to add agent:needs-attention label: ${error.message}`);
}
}

if (stop) {
try {
await github.rest.issues.addLabels({
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
labels: ['needs-human'],
body,
});
} catch (error) {
if (core) core.warning(`Failed to add needs-human label: ${error.message}`);
}

if (shouldEscalate) {
try {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
labels: ['agent:needs-attention'],
});
} catch (error) {
if (core) core.warning(`Failed to add agent:needs-attention label: ${error.message}`);
}
}

if (stop) {
try {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
labels: ['needs-human'],
});
} catch (error) {
if (core) core.warning(`Failed to add needs-human label: ${error.message}`);
}
}
} catch (error) {
const rateLimitMessage = [error?.message, error?.response?.data?.message]
.filter(Boolean)
.join(' ');
const rateLimitRemaining = toNumber(error?.response?.headers?.['x-ratelimit-remaining'], NaN);
const rateLimitHit = hasRateLimitSignal(rateLimitMessage)
|| (error?.status === 403 && rateLimitRemaining === 0);
if (rateLimitHit) {
if (core) core.warning('Keepalive summary update hit GitHub API rate limit; deferring.');
return;
}
throw error;
}
} finally {
cache?.emitMetrics?.();
Expand Down Expand Up @@ -2638,7 +2801,7 @@ async function analyzeTaskCompletion({ github, context, prNumber, baseSha, headS
return null;
}
if (!issuePatternCache.has(issueNumber)) {
issuePatternCache.set(issueNumber, new RegExp(`(^|\\D)${issueNumber}(\\D|$)`));
issuePatternCache.set(issueNumber, new RegExp(`\\b${issueNumber}\\b`));
}
return issuePatternCache.get(issueNumber);
};
Expand All @@ -2657,10 +2820,17 @@ async function analyzeTaskCompletion({ github, context, prNumber, baseSha, headS
const isTestTask = /\b(test|tests|unit\s*test|coverage)\b/i.test(task);
const issueNumber = extractIssueNumber(task);
const issuePattern = buildIssuePattern(issueNumber);
const strippedIssueTask = task
let strippedIssueTask = task
.replace(/\[[^\]]*\]\(([^)]+)\)/g, '$1')
.replace(/https?:\/\/\S+/gi, '')
.replace(/[#\d]/g, '')
.replace(/https?:\/\/\S+/gi, '');

// Remove the specific issue reference if pattern exists
if (issuePattern) {
strippedIssueTask = strippedIssueTask.replace(issuePattern, '');
}

strippedIssueTask = strippedIssueTask
.replace(/#\d+/g, '') // Remove only #number patterns
.replace(/[\[\]().]/g, '')
.trim();
const isIssueOnlyTask = Boolean(issuePattern) && strippedIssueTask === '';
Expand Down Expand Up @@ -2717,6 +2887,9 @@ async function analyzeTaskCompletion({ github, context, prNumber, baseSha, headS

// Exact file match is very high confidence
if (isIssueOnlyTask) {
if (!pr) {
core.warning('analyzeTaskCompletion: pr parameter is undefined');
}
const prTitle = pr?.title;
const prRef = pr?.head?.ref;
const prMatch = issueMatchesText(issuePattern, prTitle) || issueMatchesText(issuePattern, prRef);
Expand Down Expand Up @@ -2936,4 +3109,5 @@ module.exports = {
analyzeTaskCompletion,
autoReconcileTasks,
normaliseChecklistSection,
checkRateLimitStatus,
};
Loading
Loading