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
50 changes: 46 additions & 4 deletions .github/actions/path-classifier/classify.js
Original file line number Diff line number Diff line change
Expand Up @@ -238,9 +238,32 @@ function readContractAtRef(ref, contractPath) {
return runGit(['show', `${ref}:${contractPath}`]);
}

function isAddOnlyContractDiff(diffText, contractPath) {
return String(diffText || '')
.split(/\r?\n/)
.some((line) => line === `A\t${contractPath}`);
}

function contractAddedBetweenRefs(baseSha, headSha, contractPath) {
const added = runGit([
'diff',
'--name-status',
'--diff-filter=A',
baseSha,
headSha,
'--',
contractPath,
]);
return isAddOnlyContractDiff(added, contractPath);
}

function loadDeliveryContract(
githubContext = {},
{ readTrustedContract = readContractAtRef } = {},
{
readTrustedContract = readContractAtRef,
readBootstrapContract = readContractAtRef,
isBootstrapAddition = contractAddedBetweenRefs,
} = {},
) {
const workspace = process.env.GITHUB_WORKSPACE || process.cwd();
const relativeContractPath = '.github/scripts/sync_pr_lease_contract.js';
Expand All @@ -255,9 +278,27 @@ function loadDeliveryContract(
const source = readTrustedContract(baseSha, relativeContractPath);
return compileDeliveryContract(source, `${baseSha}:${relativeContractPath}`);
} catch {
// Stable generated deliveries fail closed when the trusted base contract
// cannot be loaded; never fall back to the candidate checkout.
return null;
// A consumer's first stable-delivery rollout necessarily predates the
// lease contract on its base. Permit only that exact add-only bootstrap:
// same repository, exact observed head, and the contract path added (not
// modified or renamed) between base and head. Maint 71 remains the final
// boundary and independently requires the exact generated head to carry
// a valid GitHub-recognized signature before it can merge.
const headSha = pullRequest?.head?.sha || '';
const headRepository = pullRequest?.head?.repo?.full_name || '';
const baseRepository = pullRequest?.base?.repo?.full_name || '';
if (!headSha || !headRepository || headRepository !== baseRepository) {
return null;
}
try {
if (!isBootstrapAddition(baseSha, headSha, relativeContractPath)) {
return null;
}
const source = readBootstrapContract(headSha, relativeContractPath);
return compileDeliveryContract(source, `${headSha}:${relativeContractPath}`);
} catch {
return null;
}
}
}

Expand Down Expand Up @@ -452,6 +493,7 @@ module.exports = {
OUTPUT_NAMES,
classifyFiles,
globToRegExp,
isAddOnlyContractDiff,
isStableDeliveryPullRequest,
listChangedFiles,
loadConfig,
Expand Down
45 changes: 45 additions & 0 deletions .github/scripts/__tests__/keepalive-loop.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -992,6 +992,51 @@ test('updateKeepaliveLoopSummary increments iteration and clears failures on suc
assert.match(github.actions[0].body, /"failure":\{\}/);
});

test('updateKeepaliveLoopSummary migrates legacy state to the selected App writer', async () => {
const existingState = [
'<!-- keepalive-loop-summary -->',
formatStateComment({
trace: 'legacy-trace',
iteration: 1,
max_iterations: 5,
failure_threshold: 3,
}),
].join('\n');
const github = buildGithubStub({
comments: [{
id: 44,
body: existingState,
html_url: 'https://example.com/44',
user: { login: 'github-actions[bot]', type: 'Bot' },
}],
});

await updateKeepaliveLoopSummary({
github,
context: buildContext(123),
core: buildCore(),
inputs: {
prNumber: 123,
action: 'run',
runResult: 'success',
gateConclusion: 'success',
tasksTotal: 2,
tasksUnchecked: 1,
keepaliveEnabled: true,
iteration: 1,
maxIterations: 5,
failureThreshold: 3,
trace: 'legacy-trace',
trusted_summary_author: 'stranske-keepalive[bot]',
},
});

assert.equal(github.actions[0].type, 'create');
assert.match(github.actions[0].body, /keepalive-loop-summary/);
assert.match(github.actions[0].body, /"trace":"legacy-trace"/);
assert.equal(github.actions.some((action) => action.commentId === 44), false);
});

test('updateKeepaliveLoopSummary ignores status-only checklist metrics for reconciliation', async () => {
const pr = {
number: 1234,
Expand Down
93 changes: 93 additions & 0 deletions .github/scripts/__tests__/path-classifier.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const {
DEFAULT_CATEGORIES,
classifyFiles,
globToRegExp,
isAddOnlyContractDiff,
isStableDeliveryPullRequest,
listChangedFiles,
loadDeliveryContract,
Expand Down Expand Up @@ -190,10 +191,102 @@ test('stable delivery fails closed when the trusted base contract is unavailable
readTrustedContract: () => {
throw new Error('base object unavailable');
},
isBootstrapAddition: () => false,
});
assert.equal(contract, null);
});

test('stable delivery bootstraps an add-only contract when the trusted base predates it', () => {
const contractSource = require('node:fs').readFileSync(
require('node:path').join(__dirname, '..', 'sync_pr_lease_contract.js'),
'utf8',
);
const observed = {};
const contract = loadDeliveryContract(deliveryContext(deliveryRecord), {
readTrustedContract: () => {
throw new Error('contract absent from base');
},
isBootstrapAddition: (baseSha, headSha, contractPath) => {
Object.assign(observed, { baseSha, headSha, contractPath });
return true;
},
readBootstrapContract: (ref, contractPath) => {
observed.bootstrapRef = ref;
observed.bootstrapPath = contractPath;
return contractSource;
},
});

assert.deepEqual(observed, {
baseSha: 'trusted-base-sha',
headSha: 'head-abc',
contractPath: '.github/scripts/sync_pr_lease_contract.js',
bootstrapRef: 'head-abc',
bootstrapPath: '.github/scripts/sync_pr_lease_contract.js',
});
assert.equal(contract.mergeEligibility(deliveryRecord, { requireSealed: true }).eligible, false);
});

test('stable delivery bootstrap fails closed without an exact head SHA', () => {
const context = deliveryContext(deliveryRecord);
context.event.pull_request.head.sha = '';
let bootstrapRead = false;
const contract = loadDeliveryContract(context, {
readTrustedContract: () => {
throw new Error('contract absent from base');
},
isBootstrapAddition: () => true,
readBootstrapContract: () => {
bootstrapRead = true;
return '';
},
});

assert.equal(contract, null);
assert.equal(bootstrapRead, false);
});

test('stable delivery bootstrap fails closed when the exact head contract is unreadable', () => {
const contract = loadDeliveryContract(deliveryContext(deliveryRecord), {
readTrustedContract: () => {
throw new Error('contract absent from base');
},
isBootstrapAddition: () => true,
readBootstrapContract: () => {
throw new Error('head object unavailable');
},
});

assert.equal(contract, null);
});

test('stable delivery bootstrap recognizes only an exact added contract path', () => {
const contractPath = '.github/scripts/sync_pr_lease_contract.js';
assert.equal(isAddOnlyContractDiff(`A\t${contractPath}`, contractPath), true);
assert.equal(isAddOnlyContractDiff(`M\t${contractPath}`, contractPath), false);
assert.equal(isAddOnlyContractDiff(`R100\told.js\t${contractPath}`, contractPath), false);
assert.equal(isAddOnlyContractDiff(`A\t${contractPath}.bak`, contractPath), false);
});

test('stable delivery bootstrap rejects fork heads even when they add the contract', () => {
let bootstrapRead = false;
const contract = loadDeliveryContract(
deliveryContext(deliveryRecord, { fork: true }),
{
readTrustedContract: () => {
throw new Error('contract absent from base');
},
isBootstrapAddition: () => true,
readBootstrapContract: () => {
bootstrapRead = true;
return '';
},
},
);
assert.equal(contract, null);
assert.equal(bootstrapRead, false);
});
Comment thread
stranske marked this conversation as resolved.

test('stable delivery reuses its trusted-base fetch for changed-file classification', () => {
const context = deliveryContext(deliveryRecord);
let fetches = 0;
Expand Down
25 changes: 23 additions & 2 deletions .github/scripts/keepalive_loop.js
Original file line number Diff line number Diff line change
Expand Up @@ -3201,12 +3201,33 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in
const delegationShouldSwitch = toBool(inputs.delegation_should_switch ?? inputs.delegationShouldSwitch, false);
const agentRoutingMode = normalise(inputs.agent_routing_mode ?? inputs.agentRoutingMode);

const { state: previousState, commentId } = await loadKeepaliveState({
const {
state: previousState,
commentId,
commentAuthorLogin,
commentAuthorType,
} = await loadKeepaliveState({
github,
context,
prNumber,
trace: stateTrace,
});
const trustedSummaryAuthor = normalise(
inputs.trusted_summary_author ?? inputs.trustedSummaryAuthor,
).toLowerCase();
const existingSummaryAuthor = normalise(commentAuthorLogin).toLowerCase();
const existingSummaryAuthorType = normalise(commentAuthorType).toLowerCase();
const migrateSummaryWriter = Boolean(
commentId &&
trustedSummaryAuthor &&
(existingSummaryAuthor !== trustedSummaryAuthor || existingSummaryAuthorType !== 'bot'),
);
if (migrateSummaryWriter) {
core?.info?.(
`Creating a trusted App-owned keepalive summary; existing writer ` +
`${existingSummaryAuthor || 'unknown'} is not ${trustedSummaryAuthor}.`,
);
}

const hasTasksTotalInput = tasksTotalInput !== undefined && tasksTotalInput !== '';
const hasTasksUncheckedInput = tasksUncheckedInput !== undefined && tasksUncheckedInput !== '';
Expand Down Expand Up @@ -4394,7 +4415,7 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in
// This prevents duplicate failure notifications on PRs

try {
let summaryCommentId = commentId;
let summaryCommentId = migrateSummaryWriter ? 0 : commentId;
const persistSummary = async (body) => {
if (summaryCommentId) {
await github.rest.issues.updateComment({
Expand Down
2 changes: 2 additions & 0 deletions .github/scripts/keepalive_state.js
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,8 @@ async function loadKeepaliveState({ github: rawGithub, context, prNumber, trace
state: loadedState,
commentId: existing.comment?.id ? Number(existing.comment.id) : 0,
commentUrl: existing.comment?.html_url || '',
commentAuthorLogin: existing.comment?.user?.login || '',
commentAuthorType: existing.comment?.user?.type || '',
};
}

Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/agents-keepalive-loop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1539,6 +1539,12 @@ jobs:
github.event.inputs.authority_challenge_claim || '' }}
AUTHORITY_CHALLENGE_SIGNING_KEY: >-
${{ secrets.KEEPALIVE_AUTHORITY_SIGNING_KEY || '' }}
KEEPALIVE_SUMMARY_WRITER: >-
${{
steps.summary_keepalive_app_token.outputs.token != '' &&
'stranske-keepalive[bot]' ||
'agents-workflows-bot[bot]'
}}
with:
github-token: >-
${{
Expand Down Expand Up @@ -1658,6 +1664,7 @@ jobs:
process.env.AUTHORITY_CHALLENGE_CLAIM || '',
authority_challenge_signing_key:
process.env.AUTHORITY_CHALLENGE_SIGNING_KEY || '',
trusted_summary_author: process.env.KEEPALIVE_SUMMARY_WRITER || '',
retry_workflow_id: 'agents-keepalive-loop.yml',
};
await updateKeepaliveLoopSummary({ github, context, core, inputs });
Expand Down
2 changes: 1 addition & 1 deletion docs/keepalive/Agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Auto-pilot pipeline:

1. **PR body is the contract**: Auto-pilot writes structured tasks into the PR body. Keepalive reads these tasks via the task appendix. If the PR body format changes, both must be updated together.

2. **Labels are handoff signals**: Auto-pilot applies the selected registry-backed `agent:<name>` label (for example, `agent:codex` or `agent:claude`) and keepalive activates. Every non-transient run/fix failure records automation-owned recovery state and explicitly dispatches a bounded retry through the active workflow (`agents-keepalive-loop.yml` in the root lane or `agents-81-gate-followups.yml` in consolidated consumers). A failed direct dispatch defers that durable lease for the hourly sweep instead of adding a sticky `agent:retry` label; after 3 failures, the current strategy pauses for the same hourly recovery sweep. It does not infer that a human is required. Each hourly sweep wakeup bypasses state debounce so current state is re-evaluated, while ordinary wakeups retain completed-runner debounce. A possible authority boundary enters an independent scheduled challenge whose durable fingerprint is derived only from the routed agent's registry-backed required credentials, shared registry authority credentials, finite permission targets, and HTTP 401/403; arbitrary runner text is never copied or persisted. The root lane mints one of the dedicated app tokens before writing the summary, and the sweep reads durable state only from a marked summary comment owned by the `stranske-keepalive[bot]` or `agents-workflows-bot[bot]` app; user comments and generic workflow-bot comments cannot nominate or replace a challenge. Only the sweep passes a valid HMAC-signed claim using `KEEPALIVE_AUTHORITY_SIGNING_KEY` and binding that fingerprint to the repository, PR, random nonce, and exact sweep run/attempt; that signed due claim alone may bypass runner debounce and force-dispatch. A generic retry or another workflow sharing `github-actions[bot]` cannot confirm the challenge. Missing signing material fails closed to an ordinary non-forced recheck. A matching second failure may record the projected human action and apply `needs-human`. A different auth failure stays automation-owned and is challenged again on the next sweep.
2. **Labels are handoff signals**: Auto-pilot applies the selected registry-backed `agent:<name>` label (for example, `agent:codex` or `agent:claude`) and keepalive activates. Every non-transient run/fix failure records automation-owned recovery state and explicitly dispatches a bounded retry through the active workflow (`agents-keepalive-loop.yml` in the root lane or `agents-81-gate-followups.yml` in consolidated consumers). A failed direct dispatch defers that durable lease for the hourly sweep instead of adding a sticky `agent:retry` label; after 3 failures, the current strategy pauses for the same hourly recovery sweep. It does not infer that a human is required. Each hourly sweep wakeup bypasses state debounce so current state is re-evaluated, while ordinary wakeups retain completed-runner debounce. A possible authority boundary enters an independent scheduled challenge whose durable fingerprint is derived only from the routed agent's registry-backed required credentials, shared registry authority credentials, finite permission targets, and HTTP 401/403; arbitrary runner text is never copied or persisted. Both the root and consolidated consumer lanes mint a dedicated keepalive or Workflows App token before writing the summary, and the sweep reads durable state only from a marked summary comment owned by the `stranske-keepalive[bot]` or `agents-workflows-bot[bot]` app; user comments and generic workflow-bot comments cannot nominate or replace a challenge. When the selected App encounters a legacy summary owned by another writer, it preserves the parsed state in a new comment owned by that App because editing the legacy comment would retain its original untrusted author. Only the sweep passes a valid HMAC-signed claim using `KEEPALIVE_AUTHORITY_SIGNING_KEY` and binding that fingerprint to the repository, PR, random nonce, and exact sweep run/attempt; that signed due claim alone may bypass runner debounce and force-dispatch. A generic retry or another workflow sharing `github-actions[bot]` cannot confirm the challenge. Missing signing material fails closed to an ordinary non-forced recheck. A matching second failure may record the projected human action and apply `needs-human`. A different auth failure stays automation-owned and is challenged again on the next sweep.

3. **Gate is the trigger**: Keepalive is event-driven via Gate `workflow_run` completion. Auto-pilot's `monitor-pr` step watches for keepalive progress. Neither polls — both react to events.

Expand Down
Loading
Loading