Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
156 changes: 151 additions & 5 deletions .github/scripts/__tests__/keepalive-loop.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,58 @@ test('evaluateKeepaliveLoop stops when round budget is exhausted', async () => {
assert.equal(result.reason, 'round-budget-exhausted');
});

test('evaluateKeepaliveLoop grants a forced recovery lease across the round budget', async () => {
const pr = {
number: 406,
head: { ref: 'feature/forced-budget-recovery', sha: 'sha-forced-budget' },
labels: [{ name: 'agent:codex' }],
body: '## Tasks\n- [ ] one\n## Acceptance Criteria\n- [ ] a\n<!-- keepalive-config: {"iteration": 5, "max_iterations": 5} -->',
};
const github = buildGithubStub({
pr,
workflowRuns: [{ head_sha: 'sha-forced-budget', conclusion: 'success' }],
});

const result = await evaluateKeepaliveLoop({
github,
context: buildContext(pr.number),
core: buildCore(),
forceRetry: true,
});

assert.equal(result.action, 'run');
assert.equal(result.reason, 'ready');
assert.equal(result.forceRetry, true);
});

test('evaluateKeepaliveLoop grants a forced recovery lease after verification exhaustion', async () => {
const pr = {
number: 408,
head: { ref: 'feature/forced-verification-recovery', sha: 'sha-forced-verification' },
labels: [{ name: 'agent:codex' }],
body: prBodyFixture.replace(/- \[ \]/g, '- [x]'),
};
const existingState = formatStateComment({
trace: 'fixture-trace',
verification: { status: 'failed', iteration: 2, attempt_count: 2 },
});
const github = buildGithubStub({
pr,
comments: [{ id: 24, body: existingState, html_url: 'https://example.com/24' }],
workflowRuns: [{ head_sha: 'sha-forced-verification', conclusion: 'success' }],
});

const result = await evaluateKeepaliveLoop({
github,
context: buildContext(pr.number),
core: buildCore(),
forceRetry: true,
});

assert.equal(result.action, 'run');
assert.equal(result.reason, 'fix-verification-gaps');
});

test('evaluateKeepaliveLoop stops at max iterations even when productive with tasks remaining', async () => {
const pr = {
number: 405,
Expand Down Expand Up @@ -2117,11 +2169,9 @@ test('updateKeepaliveLoopSummary routes repeated actual failures to automation r
action.type === 'label' && action.labels.includes('agent:retry')
);
assert.ok(retryLabel);
assert.equal(
github.actions.some((action) => action.type === 'workflow-dispatch'),
false,
'a stopped strategy must wait for the scheduled recovery sweep instead of self-recursing',
);
const retryDispatch = github.actions.find((action) => action.type === 'workflow-dispatch');
assert.ok(retryDispatch, 'a newly stopped strategy must receive one immediate recovery lease');
assert.deepEqual(retryDispatch.inputs, { pr_number: '457', force_retry: 'true' });

const humanLabel = github.actions.find((action) =>
action.type === 'label' && (
Expand Down Expand Up @@ -3523,6 +3573,55 @@ test('updateKeepaliveLoopSummary routes resource failures to automation retry',
);
});

test('automation-owned terminal stops dispatch exactly one forced recovery lease', async () => {
for (const reason of [
'round-budget-exhausted',
'verification-exhausted',
'zero-activity-infrastructure',
]) {
for (const forceRetry of [false, true]) {
const existingState = formatStateComment({
trace: `trace-terminal-${reason}-${forceRetry}`,
iteration: 5,
max_iterations: 5,
failure_threshold: 3,
failure: {},
});
const github = buildGithubStub({
comments: [{ id: 104, body: existingState, html_url: 'https://example.com/104' }],
});

await updateKeepaliveLoopSummary({
github,
context: buildContext(659),
core: buildCore(),
inputs: {
prNumber: 659,
action: 'stop',
reason,
gateConclusion: 'success',
tasksTotal: 3,
tasksUnchecked: reason === 'verification-exhausted' ? 0 : 2,
keepaliveEnabled: true,
autofixEnabled: false,
iteration: 5,
maxIterations: 5,
failureThreshold: 3,
trace: `trace-terminal-${reason}-${forceRetry}`,
forceRetry,
retry_workflow_id: 'agents-81-gate-followups.yml',
},
});

const dispatches = github.actions.filter((action) => action.type === 'workflow-dispatch');
assert.equal(dispatches.length, forceRetry ? 0 : 1, `${reason} forceRetry=${forceRetry}`);
if (!forceRetry) {
assert.deepEqual(dispatches[0].inputs, { pr_number: '659', force_retry: 'true' });
}
}
}
});

test('updateKeepaliveLoopSummary routes logic failures to automation retry', async () => {
const existingState = formatStateComment({
trace: 'trace-attention-logic',
Expand Down Expand Up @@ -3730,6 +3829,53 @@ test('updateKeepaliveLoopSummary clears stale human-blocker labels on tasks-comp
);
});

test('a successful terminal migrates and clears legacy automation attention state', async () => {
const existingState = formatStateComment({
trace: 'trace-legacy-attention',
iteration: 3,
failure_threshold: 3,
failure: { reason: 'agent-run-failed', count: 2 },
attention: {
key: 'legacy-agent-run-failed',
first_seen_at: '2026-07-01T12:00:00Z',
},
});
const github = buildGithubStub({
comments: [{ id: 47, body: existingState, html_url: 'https://example.com/47' }],
labels: ['agent:needs-attention', 'needs-human'],
});

await updateKeepaliveLoopSummary({
github,
context: buildContext(459),
core: buildCore(),
inputs: {
prNumber: 459,
action: 'stop',
reason: 'tasks-complete',
gateConclusion: 'success',
tasksTotal: 3,
tasksUnchecked: 0,
keepaliveEnabled: true,
autofixEnabled: false,
iteration: 3,
maxIterations: 5,
failureThreshold: 3,
trace: 'trace-legacy-attention',
},
});

assert.ok(github.actions.some((action) =>
action.type === 'remove-label' && action.name === 'agent:needs-attention'
));
assert.equal(
github.actions.some((action) => action.type === 'remove-label' && action.name === 'needs-human'),
false,
);
const updateAction = github.actions.find((action) => action.type === 'update');
assert.equal(parseStateComment(updateAction.body).data.attention, undefined);
});

test('evaluateKeepaliveLoop extracts agent type from agent:* labels', async () => {
const pr = {
number: 107,
Expand Down
29 changes: 21 additions & 8 deletions .github/scripts/keepalive_loop.js
Original file line number Diff line number Diff line change
Expand Up @@ -2792,11 +2792,14 @@ async function evaluateKeepaliveLoop({ github: rawGithub, context, core, payload
// An iteration is productive if it has a reasonable productivity score
const isProductive = productivityScore >= 20 && !hasRecentFailures;

// max_iterations is a hard per-PR budget. Once reached, stop dispatching
// and require a human to raise the budget or remove the blocker.
// max_iterations is the ordinary per-PR budget. Once reached, stop the
// current strategy; a single forced recovery lease may cross it once.
const hasMaxIterations = maxIterations > 0;
const reachedMaxIterations = hasMaxIterations && iteration >= maxIterations;
const shouldStopForMaxIterations = reachedMaxIterations;
// A force retry is a single, explicit recovery lease. It may cross the
// persisted round budget once; the summary step prevents a forced run from
// recursively dispatching another forced run.
const shouldStopForMaxIterations = reachedMaxIterations && !forceRetry;

// Build task appendix for the agent prompt (after state load for reconciliation info)
const taskAppendix = buildTaskAppendix(normalisedSections, checkboxCounts, state, { prBody: pr.body });
Expand Down Expand Up @@ -2825,7 +2828,7 @@ async function evaluateKeepaliveLoop({ github: rawGithub, context, core, payload
// This prevents premature stop when the verifier identifies unmet criteria.
const needsVerification = allComplete && !verificationDone && !verificationAttempted;
const needsVerificationRetry = allComplete && verificationFailed
&& verificationAttemptCount < maxVerificationAttempts;
&& (verificationAttemptCount < maxVerificationAttempts || forceRetry);

// Only treat GitHub API conflicts as definitive (mergeable_state === 'dirty')
// CI-log based conflict detection has too many false positives from commit messages
Expand Down Expand Up @@ -4244,9 +4247,14 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in
authorityEvidence.fingerprint,
].filter(Boolean).join('|');
const priorAttentionKey = normalise(previousAttention.key);
const previousAttentionHasLegacyOwnership =
Object.keys(previousAttention).length > 0 &&
!normalise(previousAttention.owner) &&
!normalise(previousAttention.disposition);
const previousAttentionAutomationOwned =
previousAttention.owner === 'automation' &&
['automation-retry', 'challenge-due'].includes(previousAttention.disposition);
(previousAttention.owner === 'automation' &&
['automation-retry', 'challenge-due'].includes(previousAttention.disposition)) ||
previousAttentionHasLegacyOwnership;
const challengeDueAt = escalationDisposition === 'challenge-due'
? new Date().toISOString()
: null;
Expand Down Expand Up @@ -4321,7 +4329,9 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in
core,
automationOwned: previousAttentionAutomationOwned,
});
if (previousAuthorityChallenge && runResult === 'success') {
if (previousAttentionAutomationOwned && cleanup.complete) {
delete newState.attention;
} else if (previousAuthorityChallenge && runResult === 'success') {
if (cleanup.complete) {
delete newState.attention;
} else {
Expand Down Expand Up @@ -4455,7 +4465,10 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in
} catch (error) {
if (core) core.warning(`Failed to add ${escalationDisposition} routing label: ${error.message}`);
}
if (escalationDisposition === 'automation-retry' && !stop) {
// Every automation-owned terminal gets one immediate recovery lease.
// A forced recovery never recursively dispatches itself; the next
// ordinary sweep may reassess the exact current state.
if (escalationDisposition === 'automation-retry' && !isForceRetry) {
Comment thread
stranske marked this conversation as resolved.
Resolved
Comment thread
stranske marked this conversation as resolved.
Resolved
try {
const retryWorkflowId = normalise(
inputs.retry_workflow_id ?? inputs.retryWorkflowId,
Expand Down
5 changes: 3 additions & 2 deletions .github/workflows/maint-52-sync-dev-versions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ jobs:

# Run sync check first
if python ../scripts/sync_dev_dependencies.py \
--check 2>&1 \
--check --pre-commit 2>&1 \
| tee /tmp/sync_output.txt; then
if [ "$uv_lock_stale" = "true" ]; then
echo "has_changes=true" >> "$GITHUB_OUTPUT"
Expand All @@ -229,7 +229,7 @@ jobs:

# Apply if not dry run
if [ "${{ inputs.dry_run }}" != "true" ]; then
python ../scripts/sync_dev_dependencies.py --apply
python ../scripts/sync_dev_dependencies.py --apply --pre-commit
if [ -f uv.lock ]; then
uv lock
fi
Expand Down Expand Up @@ -257,6 +257,7 @@ jobs:
# Use --create-if-missing to add dev deps
if python ../scripts/sync_dev_dependencies.py \
--apply \
--pre-commit \
--create-if-missing 2>&1 \
| tee /tmp/sync_output.txt; then
if grep -q "version updates" /tmp/sync_output.txt; then
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/maint-69-sync-integration-repo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ jobs:
if [ -f "pyproject.toml" ]; then
python "../workflows/scripts/sync_dev_dependencies.py" \
--apply \
--pre-commit \
Comment thread
stranske marked this conversation as resolved.
Resolved
--pin-file ".github/workflows/autofix-versions.env" \
--pyproject "pyproject.toml"
echo "✅ Synced pyproject.toml dev tool pins"
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/maint-auto-update-pypi-versions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ jobs:
run: |
echo "🔄 Syncing pyproject.toml + requirements.lock + templates"
python scripts/sync_tool_versions.py --apply
python scripts/sync_dev_dependencies.py --apply --lockfile
python scripts/sync_dev_dependencies.py --apply --lockfile --pre-commit
Comment thread
stranske marked this conversation as resolved.
Resolved
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

# security_override may bypass the weekly window, but never this gate.
- name: Validate synchronized pins before proposal
Expand All @@ -110,7 +110,7 @@ jobs:
set -euo pipefail
echo "🧪 Validating synchronized pin surfaces before opening a source PR..."
python scripts/sync_tool_versions.py --check
python scripts/sync_dev_dependencies.py --check --lockfile
python scripts/sync_dev_dependencies.py --check --lockfile --pre-commit

- name: Create PR
id: create_pr
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/maint-sync-env-from-pyproject.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ jobs:
run: |
set -euo pipefail
python scripts/sync_tool_versions.py --apply
python scripts/sync_dev_dependencies.py --apply --lockfile
python scripts/sync_dev_dependencies.py --apply --lockfile --pre-commit
Comment thread
stranske marked this conversation as resolved.
Resolved

- name: Commit and push if changed
run: |
Expand Down
4 changes: 2 additions & 2 deletions docs/keepalive/GoalsAndPlumbing.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@ If any requirement fails, keepalive stays silent—no PR comments. Operators may
- **Default limit:** Maximum of **1** concurrent agent run per PR.
- **Label override:** Respect `agents:max-parallel:<K>` when present (integer 1–5).
- **Enforcement:** Dispatch only when the count of in-progress runs is `< K`. If at cap, exit quietly after updating the run summary.
- **Round budget:** The loop also enforces `max_iterations` as a hard per-PR round budget. The default is 12 rounds unless overridden by keepalive config.
- **Budget exhaustion:** When the current iteration reaches `max_iterations`, keepalive stops that dispatch strategy and adds `agent:retry` with reason `round-budget-exhausted`. The retry owner must choose a concrete recovery such as decomposition, alternate-agent routing, CI repair, or a bounded budget change.
- **Round budget:** The loop enforces `max_iterations` as the ordinary per-PR round budget. The default is 12 rounds unless overridden by keepalive config.
- **Budget exhaustion:** When the current iteration reaches `max_iterations`, keepalive stops that dispatch strategy, records reason `round-budget-exhausted`, and dispatches one forced recovery lease. The forced run may cross the persisted budget once and cannot recursively dispatch itself; the next ordinary sweep reassesses the exact state and can select decomposition, alternate-agent routing, CI repair, or another bounded recovery.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

---

Expand Down
5 changes: 5 additions & 0 deletions docs/ops/CONSUMER_REPO_MAINTENANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,11 @@ workflow-template sync PR cannot update the env file without the matching
`pyproject.toml`, managed `.pre-commit-config.yaml` hook revisions, `requirements.lock`, and supported `requirements-dev.lock`
changes.

Managed pre-commit revisions are an explicit Maint 52 propagation surface. The
workflow passes `sync_dev_dependencies.py --pre-commit`; the script's default
check intentionally omits that surface so a Maint 68 workflow-template candidate
does not fail while the separate canonical dependency wave is still pending.

Dependabot should not be merged when it only bumps one of those shared tool pins
in `pyproject.toml`; route that change through the Workflows source pin update
path instead. Runtime dependency bumps remain normal Dependabot work.
Expand Down
27 changes: 18 additions & 9 deletions scripts/sync_dev_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
"""Sync dev tool version pins from autofix-versions.env to dependency surfaces.

This script updates the [project.optional-dependencies] dev section in pyproject.toml,
supported requirements lockfiles, and managed .pre-commit-config.yaml hook revisions
supported requirements lockfiles, and (when explicitly requested) managed
.pre-commit-config.yaml hook revisions
to use the pinned versions from the central autofix-versions.env file.

It handles both exact pins (==) and minimum version pins (>=) in pyproject.toml,
Expand All @@ -15,6 +16,7 @@
python sync_dev_dependencies.py --apply # Update pyproject.toml
python sync_dev_dependencies.py --apply --create-if-missing # Create dev deps if missing
python sync_dev_dependencies.py --apply # Syncs supported requirements lockfiles when present
python sync_dev_dependencies.py --apply --pre-commit # Include managed hook revisions
"""

from __future__ import annotations
Expand Down Expand Up @@ -363,9 +365,7 @@ def sync_lockfile(
current_version = match.group("specifier") or "(unversioned)"
if current_version.startswith("=="):
current_version = current_version[2:]
changes.append(
f"{lockfile_path.name}:{name}: " f"{current_version} -> =={target_version}"
)
changes.append(f"{lockfile_path.name}:{name}: {current_version} -> =={target_version}")
if apply:
updated_lines.append(
f"{match.group('lead')}{name}{match.group('extras') or ''}"
Expand Down Expand Up @@ -498,6 +498,14 @@ def main(argv: list[str] | None = None) -> int:
action="store_true",
help="Compatibility flag; supported requirements lockfiles are always checked",
)
parser.add_argument(
"--pre-commit",
action="store_true",
help=(
"Include managed .pre-commit-config.yaml hook revisions; Maint 52 opts in "
"after the canonical dependency wave is ready"
),
)
parser.add_argument(
"--pin-file",
type=Path,
Expand Down Expand Up @@ -539,11 +547,12 @@ def main(argv: list[str] | None = None) -> int:
changes.extend(lock_changes)
errors.extend(lock_errors)

pre_commit_changes, pre_commit_errors = sync_pre_commit_config(
PRE_COMMIT_FILE, pins, apply=args.apply
)
changes.extend(pre_commit_changes)
errors.extend(pre_commit_errors)
if args.pre_commit:
pre_commit_changes, pre_commit_errors = sync_pre_commit_config(
PRE_COMMIT_FILE, pins, apply=args.apply
)
changes.extend(pre_commit_changes)
errors.extend(pre_commit_errors)

if errors:
for err in errors:
Expand Down
Loading
Loading