Skip to content
Merged
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
152 changes: 151 additions & 1 deletion .github/scripts/__tests__/keepalive-reporter-applicability.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const { classifyReporterRun } = require('../keepalive_reporter_applicability.js');
const {
classifyReporterRun,
recoverReporterAuthority,
} = require('../keepalive_reporter_applicability.js');
const { formatStateComment, loadKeepaliveState } = require('../keepalive_state.js');

const root = path.resolve(__dirname, '../../..');
const head = 'a'.repeat(40);
Expand Down Expand Up @@ -34,6 +38,37 @@ function setup(source, title, overrides = {}) {
return { github, reads: () => reads };
}

function recoveryGithub(state) {
const comments = [{
id: 91,
body: '<!-- keepalive-loop-summary -->\n' + formatStateComment(state),
html_url: 'https://example.com/91',
user: { login: 'agents-workflows-bot[bot]', type: 'Bot' },
}];
const actions = [];
return {
actions,
rest: {
issues: {
listComments: async () => ({ data: comments }),
getComment: async ({ comment_id: commentId }) => ({
data: comments.find((comment) => comment.id === commentId),
}),
updateComment: async ({ comment_id: commentId, body }) => {
comments.find((comment) => comment.id === commentId).body = body;
actions.push({ type: 'update', commentId, body });
return { data: { id: commentId } };
},
createComment: async ({ body }) => {
actions.push({ type: 'create', body });
return { data: { id: 92, html_url: 'https://example.com/92' } };
},
},
},
paginate: async (fn, params) => (await fn(params)).data,
};
}

for (const source of sources) {
test(`${source}: ordinary unassociated dispatch skips only after absent index`, async () => {
const { github, reads } = setup(source, 'keepalive-dispatch/v1 ordinary');
Expand Down Expand Up @@ -98,3 +133,118 @@ test('associated runs avoid the unassociated locator altogether', async () => {
lookupTarget: async () => { throw new Error('unexpected lookup'); } });
assert.deepEqual(result, { status: 'continue' });
});

for (const status of ['released', 'reopened']) {
test(`delayed ${status} recovery is located, reconciled, and projected after summary retry`, async () => {
const previousGeneration = 'b'.repeat(64);
const nextGeneration = 'c'.repeat(64);
const ownerAttempt = 'stranske/repo:12345:2';
const github = recoveryGithub({
running: false,
attention: {
owner: 'automation', disposition: 'automation-retry', generation: '',
boundary_fingerprint: '', challenge_due_at: null, expires_at: '',
recovery_generation: previousGeneration, recovery_owner_attempt: ownerAttempt,
},
});
const receiptName = status === 'released' ? 'released_receipt' : 'recovered_receipt';
const recovery = {
status,
previousGeneration,
state: {
generation: nextGeneration,
boundary_fingerprint: 'd'.repeat(64),
[receiptName]: { owner_attempt: ownerAttempt },
due_at: '2026-09-30T01:00:00Z',
expires_at: '2026-09-30T13:00:00Z',
},
};
const context = { repo: { owner: 'stranske', repo: 'repo' } };
const args = {
github, context, run, workerEvidence: 'not-started',
writerLogin: 'agents-workflows-bot[bot]',
makeRequest: () => 'request',
lookupTarget: async ({ repository, ownerAttempt: attempt }) => {
assert.equal(repository, 'stranske/repo');
assert.equal(attempt, ownerAttempt);
return { prNumber: 42 };
},
reconcileAttempt: async (input) => {
assert.equal(input.request, 'request');
assert.equal(input.ownerAttempt, ownerAttempt);
assert.equal(input.prNumber, 42);
return recovery;
},
};
const first = await recoverReporterAuthority(args);
assert.equal(first.status, 'projected');
assert.equal(first.projection.reason, 'recovered-summary-projected');
const second = await recoverReporterAuthority(args);
assert.equal(second.projection.reason, 'already-projected');
assert.equal(github.actions.filter((action) => action.type === 'update').length, 1);
const loaded = await loadKeepaliveState({ github, context, prNumber: 42, trace: '' });
assert.equal(loaded.state.attention.disposition, 'challenge-due');
assert.equal(loaded.state.attention.generation, nextGeneration);
assert.equal(loaded.state.attention.recovery_owner_attempt, ownerAttempt);
});
}

test('404 reconciliation preserves the owner-attempt binding for normal reporting', async () => {
const missing = new Error('not found');
missing.status = 404;
const result = await recoverReporterAuthority({
github: {}, context: { repo: { owner: 'stranske', repo: 'repo' } }, run,
workerEvidence: 'started', writerLogin: 'agents-workflows-bot[bot]', prNumber: 42,
makeRequest: () => 'request',
reconcileAttempt: async () => { throw missing; },
});
assert.equal(result.status, 'continue');
assert.equal(result.ownerAttempt, 'stranske/repo:12345:2');
});

test('non-released reconciliation preserves the owner-attempt binding for normal reporting', async () => {
const result = await recoverReporterAuthority({
github: {}, context: { repo: { owner: 'stranske', repo: 'repo' } }, run,
workerEvidence: 'started', writerLogin: 'agents-workflows-bot[bot]', prNumber: 42,
makeRequest: () => 'request',
reconcileAttempt: async () => ({ status: 'owned' }),
});
assert.equal(result.status, 'continue');
assert.equal(result.ownerAttempt, 'stranske/repo:12345:2');
});

test('recovery rejects unknown worker evidence before authority mutation', async () => {
await assert.rejects(recoverReporterAuthority({
github: {}, context: { repo: { owner: 'stranske', repo: 'repo' } }, run,
workerEvidence: 'unknown', writerLogin: 'agents-workflows-bot[bot]',
makeRequest: () => { throw new Error('must not request'); },
}), /execution evidence is unknown/);
});

test('recovery rejects a delayed summary marker for a different attempt or generation', async () => {
const github = recoveryGithub({
running: false,
attention: {
owner: 'automation', disposition: 'automation-retry', generation: '',
recovery_generation: 'e'.repeat(64),
recovery_owner_attempt: 'stranske/repo:999:1',
},
});
await assert.rejects(recoverReporterAuthority({
github,
context: { repo: { owner: 'stranske', repo: 'repo' } },
run,
workerEvidence: 'not-started',
writerLogin: 'agents-workflows-bot[bot]',
makeRequest: () => 'request',
lookupTarget: async () => ({ prNumber: 42 }),
reconcileAttempt: async () => ({
status: 'released', previousGeneration: 'b'.repeat(64), state: {
generation: 'c'.repeat(64), boundary_fingerprint: 'd'.repeat(64),
released_receipt: { owner_attempt: 'stranske/repo:12345:2' },
due_at: '2026-09-30T01:00:00Z', expires_at: '2026-09-30T13:00:00Z',
},
}),
}), /does not match the recovered generation/);
assert.equal(github.actions.length, 0);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
59 changes: 57 additions & 2 deletions .github/scripts/keepalive_reporter_applicability.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
'use strict';

const { findAuthorityPrForAttempt, requester } = require('./keepalive_authority_state.js');
const {
findAuthorityPrForAttempt,
reconcileFailedAuthorityAttempt,
requester,
} = require('./keepalive_authority_state.js');
const { projectRecoveredAuthorityState } = require('./keepalive_state.js');
const { withRetry } = require('./github-api-with-retry.js');

const DISPATCH_TITLE = 'keepalive-dispatch/v1 ';
Expand Down Expand Up @@ -47,4 +52,54 @@ async function classifyReporterRun({ github, owner, repo, run, lookupTarget = fi
return { status: 'skip' };
}

module.exports = { classifyReporterRun };
async function recoverReporterAuthority({
github,
context,
run,
workerEvidence,
writerLogin,
prNumber = Number(run.pull_requests?.[0]?.number || 0),
lookupTarget = findAuthorityPrForAttempt,
reconcileAttempt = reconcileFailedAuthorityAttempt,
projectRecovery = projectRecoveredAuthorityState,
makeRequest = requester,
}) {
if (!['started', 'not-started'].includes(workerEvidence)) {
throw new Error('Originating worker execution evidence is unknown');
}
const owner = context.repo.owner;
const repo = context.repo.repo;
const repository = `${owner}/${repo}`;
const ownerAttempt = `${repository}:${run.id}:${run.run_attempt || 1}`.toLowerCase();
const request = makeRequest(github);
let authorityTarget;
if (!prNumber) {
authorityTarget = await lookupTarget({ request, repository, ownerAttempt });
prNumber = Number(authorityTarget?.prNumber || 0);
}
if (!prNumber) {
throw new Error('No PR association or authoritative attempt target for failed run');
}
let reconciliation;
try {
reconciliation = await reconcileAttempt({
request, repository, prNumber, ownerAttempt, workerEvidence,
});
} catch (error) {
if (error.status === 404) {
return { status: 'continue', prNumber, ownerAttempt, authorityTarget };
}
throw error;
}
if (['released', 'reopened'].includes(reconciliation.status)) {
const projection = await projectRecovery({
github, context, prNumber, recovery: reconciliation, writerLogin,
});
return {
status: 'projected', prNumber, ownerAttempt, authorityTarget, reconciliation, projection,
};
}
return { status: 'continue', prNumber, ownerAttempt, authorityTarget, reconciliation };
}

module.exports = { classifyReporterRun, recoverReporterAuthority };
60 changes: 19 additions & 41 deletions .github/workflows/agents-keepalive-loop-reporter.yml
Original file line number Diff line number Diff line change
Expand Up @@ -173,10 +173,11 @@ jobs:
return;
}

const { loadKeepaliveState, projectRecoveredAuthorityState } =
require('./.github/scripts/keepalive_state.js');
const { loadKeepaliveState } = require('./.github/scripts/keepalive_state.js');
const { updateKeepaliveLoopSummary } = require('./.github/scripts/keepalive_loop.js');
const { getWorkerExecutionEvidence } = require('./.github/scripts/keepalive_worker_evidence.js');
const { recoverReporterAuthority } =
require('./.github/scripts/keepalive_reporter_applicability.js');

const workerEvidence = await getWorkerExecutionEvidence(
github, context.repo.owner, context.repo.repo, run.id, run.run_attempt || 1,
Expand All @@ -187,48 +188,25 @@ jobs:
core.setFailed('Originating worker execution evidence is unknown; retry this reporter');
return;
}
const ownerAttempt =
`${context.repo.owner}/${context.repo.repo}:${run.id}:${run.run_attempt || 1}`.toLowerCase();
const { findAuthorityPrForAttempt, reconcileFailedAuthorityAttempt, requester } =
require('./.github/scripts/keepalive_authority_state.js');
const authorityRequest = requester(github);
let authorityTarget;
if (!prNumber) {
try {
authorityTarget = await findAuthorityPrForAttempt({
request: authorityRequest,
repository: `${context.repo.owner}/${context.repo.repo}`,
ownerAttempt,
});
} catch (error) {
core.setFailed(`Authority attempt target lookup unavailable: ${error.message}`);
return;
}
}
prNumber ||= authorityTarget?.prNumber || 0;
if (!prNumber) {
core.setFailed('No PR association or authoritative attempt target for failed run');
return;
}
let authorityRecovery;
try {
const reconciliation = await reconcileFailedAuthorityAttempt({
request: authorityRequest,
repository: `${context.repo.owner}/${context.repo.repo}`,
prNumber, ownerAttempt, workerEvidence,
authorityRecovery = await recoverReporterAuthority({
github, context, run, workerEvidence,
writerLogin: process.env.KEEPALIVE_SUMMARY_WRITER || '', prNumber,
});
core.info(`Attempt-bound authority reconciliation: ${reconciliation.status}`);
if (['released', 'reopened'].includes(reconciliation.status)) {
await projectRecoveredAuthorityState({
github, context, prNumber, recovery: reconciliation,
writerLogin: process.env.KEEPALIVE_SUMMARY_WRITER || '',
});
return;
}
} catch (error) {
if (error.status !== 404) {
core.setFailed(`Authority reconciliation unavailable: ${error.message}`);
return;
}
core.setFailed(`Authority reconciliation unavailable: ${error.message}`);
return;
}
prNumber = authorityRecovery.prNumber;
const authorityTarget = authorityRecovery.authorityTarget;
Comment thread
stranske marked this conversation as resolved.
Comment thread
stranske marked this conversation as resolved.
const ownerAttempt = authorityRecovery.ownerAttempt;
core.info(
`Attempt-bound authority reconciliation: ` +
`${authorityRecovery.reconciliation?.status || authorityRecovery.status}`
);
if (authorityRecovery.status === 'projected') {
return;
}

const { state } = await loadKeepaliveState({ github, context, prNumber, trace: '' });
Expand Down
8 changes: 4 additions & 4 deletions config/template-drift-allowlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -144,11 +144,11 @@ fingerprint_refreshed = 2026-09-02
[pair.12]
main = .github/workflows/agents-keepalive-loop-reporter.yml
template = templates/consumer-repo/.github/workflows/agents-keepalive-loop-reporter.yml
main_sha256 = 3040dc8ebb342c988d674cee9d149c218c48cacf3a01b2f7152b7fc521c8b1c4
template_sha256 = ff5a929fc27f139f35718a7c40c2d1f16b9421b03b5805d62e8e499ceb904649
divergence = Non-consolidated authority recovery re-reviewed 2026-09-28: both reporters now remain active for failed or cancelled originating workflows regardless of sweep mode. The Workflows root reporter subscribes to both Agents Gate Followups and Agents Keepalive Loop, while the consumer reporter subscribes only to Agents Gate Followups because both consolidated and non-consolidated consumer sweeps can dispatch that workflow. Root in-repo setup and consumer state fingerprinting with exact run attempt and SHA-pinned actions remain intentional. Do not align wholesale, because it would remove either the root workflow coverage or the consumer deployment contract.
main_sha256 = a09cee047220471859049924c2c9c9a0b6d5f66f879619fd5d04c1ef10bcbebd
template_sha256 = 7c86a45829aa4cf7524df75acdc04ced58b1507ea6dccc27e6d278e82cdb5cef
divergence = Delayed authority recovery boundary 2026-09-29: both reporters now call the shared, directly tested applicability helper for exact-attempt reconciliation and recovered-summary projection. Applied identically while preserving the existing root subscription/setup and consumer state-fingerprint/action-pin deployment differences; the underlying divergence was not re-reviewed. Prior divergence: Non-consolidated authority recovery re-reviewed 2026-09-28: both reporters remain active for failed or cancelled originating workflows regardless of sweep mode. The Workflows root reporter subscribes to both Agents Gate Followups and Agents Keepalive Loop, while the consumer reporter subscribes only to Agents Gate Followups because both consolidated and non-consolidated consumer sweeps can dispatch that workflow. Root in-repo setup and consumer state fingerprinting with exact run attempt and SHA-pinned actions remain intentional. Do not align wholesale, because it would remove either the root workflow coverage or the consumer deployment contract.
divergence_reviewed = 2026-09-28
fingerprint_refreshed = 2026-09-28
fingerprint_refreshed = 2026-09-29

[pair.13]
main = .github/workflows/agents-keepalive-sweep.yml
Expand Down
Loading
Loading