diff --git a/.github/scripts/agent_registry.js b/.github/scripts/agent_registry.js index 4ac6ce44..f530eb53 100644 --- a/.github/scripts/agent_registry.js +++ b/.github/scripts/agent_registry.js @@ -313,15 +313,15 @@ function resolveAgentRoutingFromLabels(labels, options = {}) { if (explicitRequested.length > 1) { throw new Error(`Multiple agent labels present: ${explicitRequested.join(', ')}`); } - if (hasAuto && explicitRequested.length > 0) { - throw new Error(`Multiple agent labels present: auto, ${explicitRequested[0]}`); - } - let mode = 'default'; let agentKey = registry.default_agent; let requested = null; - if (explicitRequested.length === 1) { + if (hasAuto && explicitRequested.length === 1) { + mode = 'auto'; + agentKey = explicitRequested[0]; + requested = 'auto'; + } else if (explicitRequested.length === 1) { mode = 'explicit'; agentKey = explicitRequested[0]; requested = agentKey; diff --git a/.github/scripts/error_classifier.js b/.github/scripts/error_classifier.js index 069bb182..a23fbbd4 100644 --- a/.github/scripts/error_classifier.js +++ b/.github/scripts/error_classifier.js @@ -48,6 +48,9 @@ const TRANSIENT_PATTERNS = [ 'codex-session', 'existing changes', 'how would you like me to proceed', + 'fetch failed', + 'network error', + 'aborterror', ]; const AUTH_PATTERNS = [ @@ -249,6 +252,14 @@ function classifyByMessage(message) { return null; } +const TRANSIENT_NETWORK_CODES = new Set([ + 'ECONNRESET', + 'ECONNREFUSED', + 'ETIMEDOUT', + 'EAI_AGAIN', + 'ENOTFOUND', +]); + function classifyError(error) { const message = normaliseMessage(error); const preview = message ? message.slice(0, 50) : 'unknown'; @@ -260,8 +271,16 @@ function classifyError(error) { const statusCategory = status ? classifyByStatus(status, message) : null; const messageCategory = classifyByMessage(message); + const causeCode = String(error?.cause?.code || '').toUpperCase(); + const ownCode = !status && Object.prototype.hasOwnProperty.call(error || {}, 'code') + ? String(error.code).toUpperCase() + : ''; + const networkTransient = TRANSIENT_NETWORK_CODES.has(causeCode) || TRANSIENT_NETWORK_CODES.has(ownCode) + || error?.name === 'AbortError'; - const category = statusCategory || messageCategory || ERROR_CATEGORIES.unknown; + const category = networkTransient + ? ERROR_CATEGORIES.transient + : (statusCategory || messageCategory || ERROR_CATEGORIES.unknown); return { category, diff --git a/.github/scripts/gate-fork-status-publication.js b/.github/scripts/gate-fork-status-publication.js new file mode 100644 index 00000000..a8d2ad2c --- /dev/null +++ b/.github/scripts/gate-fork-status-publication.js @@ -0,0 +1,259 @@ +'use strict'; + +const GATE_CONTEXT = 'Gate / gate'; +const GATE_PATH = '.github/workflows/pr-00-gate.yml'; +const SUMMARY_JOB_NAMES = new Set(['summary', 'gate-summary']); +const BLOCKED_PREFIXES = [ + '.github/actions/', + '.github/scripts/', + '.github/workflows/', +]; +const BLOCKED_FILES = new Set([ + '.github/path-classification.yml', + 'tools/post_ci_summary.py', +]); + +function runSnapshot(run) { + const attempt = Number(run?.run_attempt); + if (!Number.isInteger(attempt) || attempt < 1) { + throw new Error('Gate run attempt is missing or invalid'); + } + return { + id: Number(run.id), + workflowId: Number(run.workflow_id), + headSha: run.head_sha, + attempt, + status: run.status, + conclusion: run.conclusion ?? null, + }; +} + +function assertRunUnchanged(snapshot, run) { + const current = runSnapshot(run); + for (const key of Object.keys(snapshot)) { + if (current[key] !== snapshot[key]) { + throw new Error(`Gate run ${key} changed before publication`); + } + } +} + +function collectChangedPaths(pr, files) { + const expected = Number(pr?.changed_files); + if (!Number.isInteger(expected) || expected < 0) { + throw new Error('Pull request changed-file count is missing or invalid'); + } + if (!Array.isArray(files) || files.length !== expected) { + throw new Error(`Pull request changed-file evidence is incomplete (${files?.length ?? 'missing'}/${expected})`); + } + + const filenames = new Set(); + const paths = []; + for (const file of files) { + const filename = typeof file?.filename === 'string' ? file.filename.trim() : ''; + if (!filename || filenames.has(filename)) { + throw new Error('Pull request changed-file evidence is malformed or duplicated'); + } + filenames.add(filename); + paths.push(filename); + if (file.status === 'renamed') { + const previous = typeof file.previous_filename === 'string' + ? file.previous_filename.trim() + : ''; + if (!previous) throw new Error('Renamed file is missing previous_filename'); + paths.push(previous); + } else if (typeof file.previous_filename === 'string' && file.previous_filename.trim()) { + paths.push(file.previous_filename.trim()); + } + } + return paths; +} + +function publicationState({ run, jobs, changedFiles }) { + if (changedFiles.some(path => BLOCKED_FILES.has(path) || BLOCKED_PREFIXES.some(prefix => path.startsWith(prefix)))) { + return { state: 'error', description: 'Gate controls changed; trusted review required' }; + } + if (run.status !== 'completed') { + return { state: 'pending', description: 'Trusted Gate run is in progress' }; + } + const summaries = jobs.filter(job => SUMMARY_JOB_NAMES.has(job.name)); + if (summaries.length !== 1 || jobs.some(job => job.status !== 'completed')) { + return { state: 'error', description: 'Gate job set is missing or incomplete' }; + } + const failed = new Set(['failure', 'cancelled', 'timed_out', 'action_required', 'startup_failure']); + if (run.conclusion === 'success' && summaries[0].conclusion === 'success' && !jobs.some(job => failed.has(job.conclusion))) { + return { state: 'success', description: 'Trusted Gate completed successfully' }; + } + if (failed.has(run.conclusion) || jobs.some(job => failed.has(job.conclusion))) { + return { state: 'failure', description: 'Trusted Gate reported a failing job' }; + } + return { state: 'error', description: 'Gate conclusion is not an explicit success' }; +} + +async function paginate(retry, method, params) { + return retry.paginateWithRetry(method, { ...params, per_page: 100 }); +} + +async function getFreshRun({ retry, owner, repo, runId }) { + return (await retry.withRetry(client => + client.rest.actions.getWorkflowRun({ owner, repo, run_id: runId }) + )).data; +} + +async function resolvePullRequest({ github, retry, owner, repo, run }) { + const associated = Array.isArray(run.pull_requests) ? run.pull_requests : []; + for (const item of associated) { + if (!item || !Number(item.number)) continue; + const pr = (await retry.withRetry(client => + client.rest.pulls.get({ owner, repo, pull_number: Number(item.number) }) + )).data; + if (pr.state === 'open' && pr.head?.sha === run.head_sha) return pr; + } + + const candidates = await paginate(retry, github.rest.pulls.list, { owner, repo, state: 'open' }); + const matches = candidates.filter(pr => pr.head?.sha === run.head_sha); + if (matches.length !== 1) { + throw new Error(`Expected one open PR at Gate head ${run.head_sha}; found ${matches.length}`); + } + return (await retry.withRetry(client => + client.rest.pulls.get({ owner, repo, pull_number: matches[0].number }) + )).data; +} + +function validateBinding({ run, workflow, pr, repository }) { + if (run.event !== 'pull_request') throw new Error(`Unexpected Gate event ${run.event}`); + if (run.repository?.id !== repository.id) throw new Error('Gate run repository does not match publisher repository'); + if (workflow.path !== GATE_PATH || Number(workflow.id) !== Number(run.workflow_id) || run.name !== 'Gate') { + throw new Error('Run is not the canonical Gate workflow'); + } + if (pr.state !== 'open') throw new Error('Pull request is no longer open'); + if (pr.base?.repo?.id !== repository.id) throw new Error('PR base repository does not match publisher repository'); + if (!repository.default_branch || pr.base?.ref !== repository.default_branch) { + throw new Error('PR base is not the trusted default branch'); + } + if (pr.head?.sha !== run.head_sha) throw new Error('PR head no longer matches Gate head'); + if (!pr.head?.repo?.id || pr.head.repo.id === repository.id) throw new Error('Publisher only handles fork pull requests'); + if (Number(run.head_repository?.id) !== Number(pr.head.repo.id)) { + throw new Error('Gate head repository does not match pull request head repository'); + } +} + +async function jobsForAttempt({ github, retry, owner, repo, run }) { + const attempt = runSnapshot(run).attempt; + if (!github.rest.actions.listJobsForWorkflowRunAttempt) { + throw new Error('Attempt-bound Gate jobs API is unavailable'); + } + return paginate(retry, github.rest.actions.listJobsForWorkflowRunAttempt, { + owner, repo, run_id: run.id, attempt_number: attempt, + }); +} + +async function assertLatestAttempt({ github, retry, owner, repo, run }) { + const runs = await paginate(retry, github.rest.actions.listWorkflowRuns, { + owner, repo, workflow_id: run.workflow_id, event: 'pull_request', head_sha: run.head_sha, + }); + const newer = runs.find(candidate => + candidate.id !== run.id && + (Number(candidate.run_number) > Number(run.run_number) || + (Number(candidate.run_number) === Number(run.run_number) && Number(candidate.run_attempt) > Number(run.run_attempt))) + ); + if (newer) throw new Error(`Gate run ${run.id} was superseded by ${newer.id}`); +} + +async function publishGateForkStatus({ github, context, core }) { + const payloadRun = context.payload.workflow_run; + if (!payloadRun?.id) throw new Error('workflow_run id is required'); + const { owner, repo } = context.repo; + const repository = context.payload.repository; + + // This privileged publisher deliberately uses only the workflow token. The + // shared wrapper supplies bounded retry/backoff, while env:{} prevents PAT or + // App credential rotation from widening this job's authority. + const { createTokenAwareRetry } = require('./github-api-with-retry.js'); + const retry = await createTokenAwareRetry({ + github, + core, + env: {}, + task: 'gate-fork-status-publication', + }); + + let run = await getFreshRun({ retry, owner, repo, runId: payloadRun.id }); + const workflow = (await retry.withRetry(client => + client.rest.actions.getWorkflow({ owner, repo, workflow_id: run.workflow_id }) + )).data; + let pr = await resolvePullRequest({ github, retry, owner, repo, run }); + if (pr.head?.repo?.id === repository.id) { + core.info(`PR #${pr.number} is not from a fork; the Gate summary remains its status writer.`); + return { state: 'skipped', description: 'Same-repository PR' }; + } + validateBinding({ run, workflow, pr, repository }); + const evaluatedRun = runSnapshot(run); + const evaluatedPr = { + headSha: pr.head.sha, + baseRepoId: pr.base.repo.id, + baseRef: pr.base.ref, + changedFiles: Number(pr.changed_files), + }; + await assertLatestAttempt({ github, retry, owner, repo, run }); + + const files = await paginate(retry, github.rest.pulls.listFiles, { + owner, repo, pull_number: pr.number, + }); + const changedPaths = collectChangedPaths(pr, files); + const jobs = run.status === 'completed' + ? await jobsForAttempt({ github, retry, owner, repo, run }) + : []; + const result = publicationState({ run, jobs, changedFiles: changedPaths }); + + // Pagination can outlive a rerun, so read statuses before the final binding + // checks for both replay suppression and a new write. + const statuses = await paginate(retry, github.rest.repos.listCommitStatusesForRef, { + owner, repo, ref: pr.head.sha, + }); + + // Re-read both resources after pagination. A force-push or rerun between the + // earlier inspection and this point must never bless a stale SHA. + run = await getFreshRun({ retry, owner, repo, runId: payloadRun.id }); + pr = (await retry.withRetry(client => + client.rest.pulls.get({ owner, repo, pull_number: pr.number }) + )).data; + validateBinding({ run, workflow, pr, repository }); + assertRunUnchanged(evaluatedRun, run); + if ( + pr.head.sha !== evaluatedPr.headSha || + Number(pr.base?.repo?.id) !== Number(evaluatedPr.baseRepoId) || + pr.base?.ref !== evaluatedPr.baseRef || + Number(pr.changed_files) !== evaluatedPr.changedFiles + ) { + throw new Error('Pull request binding changed before publication'); + } + await assertLatestAttempt({ github, retry, owner, repo, run }); + + const current = statuses.find(status => status.context === GATE_CONTEXT); + if (current?.state === result.state && current?.target_url === run.html_url) { + core.info(`Gate status already ${result.state} for ${pr.head.sha}; no write needed.`); + return result; + } + + await retry.withRetry(client => client.rest.repos.createCommitStatus({ + owner, + repo, + sha: pr.head.sha, + state: result.state, + context: GATE_CONTEXT, + description: result.description, + target_url: run.html_url, + }), { maxRetries: 0 }); + core.notice(`Published ${GATE_CONTEXT}=${result.state} for fork PR #${pr.number} at ${pr.head.sha}.`); + return result; +} + +module.exports = { + GATE_CONTEXT, + GATE_PATH, + assertRunUnchanged, + collectChangedPaths, + publicationState, + publishGateForkStatus, + runSnapshot, + validateBinding, +}; diff --git a/.github/scripts/github-api-with-retry.js b/.github/scripts/github-api-with-retry.js index 520b1b66..f8793a42 100755 --- a/.github/scripts/github-api-with-retry.js +++ b/.github/scripts/github-api-with-retry.js @@ -119,6 +119,8 @@ function recordRateLimitIncident(error, options = {}, contextInfo = {}) { const DEFAULT_BASE_DELAY_MS = 1000; const DEFAULT_MAX_DELAY_MS = 30000; +/** Honor Retry-After / rate-limit reset without the exponential-backoff cap. */ +const RATE_LIMIT_BACKOFF_CAP_MS = 3_600_000; const DEFAULT_MAX_RETRIES = 3; const RATE_LIMIT_THRESHOLD = 500; @@ -802,27 +804,46 @@ function resolveMaxRetries(operation, maxRetriesByOperation) { return maxRetriesByOperation.unknown ?? DEFAULT_RETRY_LIMITS.unknown; } -function calculateWaitUntilReset(resetTimestamp, nowMs) { +function calculateWaitUntilReset(resetTimestamp, nowMs, capMs = RATE_LIMIT_BACKOFF_CAP_MS) { if (!Number.isFinite(resetTimestamp)) { return DEFAULT_BASE_DELAY_MS; } const now = Number.isFinite(nowMs) ? nowMs : Date.now(); const resetTime = resetTimestamp * 1000; const waitTime = resetTime - now; - return Math.max(1000, Math.min(waitTime + 1000, 60000)); + return Math.max(1000, Math.min(waitTime + 1000, capMs)); +} + +function headersObjectFromFetchResponse(response) { + const headers = {}; + if (response?.headers?.forEach) { + response.headers.forEach((value, key) => { + headers[key] = value; + }); + } + return headers; +} + +function attachFetchResponseToError(error, response) { + error.status = response.status; + error.response = { + status: response.status, + headers: headersObjectFromFetchResponse(response), + }; + return error; } function computeRetryDelayMs({ error, attempt, baseDelay, maxDelay, backoffFn, nowMs }) { const headers = normaliseHeaders(error?.response?.headers || error?.headers); const retryAfter = parseInt(headers['retry-after'], 10); if (Number.isFinite(retryAfter) && retryAfter >= 0) { - return Math.min(retryAfter * 1000, maxDelay); + return Math.min(retryAfter * 1000, RATE_LIMIT_BACKOFF_CAP_MS); } const remaining = parseInt(headers['x-ratelimit-remaining'], 10); const reset = parseInt(headers['x-ratelimit-reset'], 10); if (Number.isFinite(remaining) && remaining <= 0 && Number.isFinite(reset)) { - return Math.min(calculateWaitUntilReset(reset, nowMs), maxDelay); + return calculateWaitUntilReset(reset, nowMs, RATE_LIMIT_BACKOFF_CAP_MS); } return Math.min(backoffFn(attempt, baseDelay, maxDelay), maxDelay); @@ -895,6 +916,67 @@ async function withGithubApiRetry(apiCall, options = {}) { throw lastError || new Error('GitHub API call failed after retries'); } +function createGithubFetchRequester({ + token, + fetchImpl = globalThis.fetch, + apiUrl = process.env.GITHUB_API_URL || 'https://api.github.com', + timeoutMs = 15_000, +} = {}) { + if (typeof token !== 'string' || !token) { + throw new Error('GitHub API token unavailable'); + } + if (typeof fetchImpl !== 'function') { + throw new Error('fetch is unavailable for GitHub API requests'); + } + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new Error('GitHub API request timeout must be positive'); + } + + return async (method, path, body) => { + const operation = method === 'GET' ? 'read' : 'write'; + return withGithubApiRetry(async () => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchImpl(`${apiUrl}${path}`, { + method, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: controller.signal, + }); + const text = await response.text(); + let data = {}; + if (text) { + try { + data = JSON.parse(text); + } catch { + const error = new Error( + `GitHub API ${method} ${path} returned non-JSON content (${response.status})`, + ); + throw attachFetchResponseToError(error, response); + } + } + if (response.ok) return data; + const error = new Error( + `GitHub API ${method} ${path} failed (${response.status}): ${data.message || 'unknown error'}`, + ); + throw attachFetchResponseToError(error, response); + } finally { + clearTimeout(timeout); + } + }, { + operation, + label: `GitHub API ${method} ${path}`, + maxRetriesByOperation: { read: 2, write: 0, dispatch: 0, admin: 0, unknown: 0 }, + }); + }; +} + // =========================================================================== // Rate-limit-aware pagination/backoff helpers (absorbed from former // api-helpers.js). paginateWithBackoff/checkRateLimitStatus wrap the client @@ -1146,6 +1228,7 @@ module.exports = { calculateWaitUntilReset, computeRetryDelayMs, withGithubApiRetry, + createGithubFetchRequester, // Rate-limit-aware pagination/backoff (former api-helpers.js) paginateWithBackoff, withBackoff, diff --git a/.github/scripts/github-rate-limited-wrapper.js b/.github/scripts/github-rate-limited-wrapper.js index a6ffde39..f7d8e920 100644 --- a/.github/scripts/github-rate-limited-wrapper.js +++ b/.github/scripts/github-rate-limited-wrapper.js @@ -89,6 +89,26 @@ async function createRateLimitedGithub(options = {}) { return method; } + /** + * Proxy invariant: non-configurable metadata on the target must be returned + * as-is (not bound), or `get` violates the invariant for function properties. + */ + function readProxyProperty(target, prop) { + const descriptor = Object.getOwnPropertyDescriptor(target, prop); + if ( + descriptor + && descriptor.configurable === false + && descriptor.writable === false + ) { + return descriptor.value; + } + const value = target[prop]; + if (typeof value === 'function') { + return value.bind(target); + } + return value; + } + function createNamespaceProxy(namespace, pathPrefix) { return new Proxy(namespace, { get(target, prop) { @@ -188,12 +208,8 @@ async function createRateLimitedGithub(options = {}) { return createWrappedPaginate(target.paginate); } - // Pass through other properties - const value = target[prop]; - if (typeof value === 'function') { - return value.bind(target); - } - return value; + // Pass through other properties (preserve non-configurable metadata) + return readProxyProperty(target, prop); }, }); diff --git a/.github/scripts/keepalive_authority_state.js b/.github/scripts/keepalive_authority_state.js index bcd232ad..06ec97bd 100644 --- a/.github/scripts/keepalive_authority_state.js +++ b/.github/scripts/keepalive_authority_state.js @@ -3,11 +3,12 @@ // This branch is the authority for challenge generations and receipts. PR comments // are presentation only: a comment PATCH cannot provide a conditional write. const crypto = require('node:crypto'); -const { withRetry } = require('./github-api-with-retry.js'); +const { createGithubFetchRequester, withRetry } = require('./github-api-with-retry.js'); const BRANCH = 'keepalive-authority-state'; const HEX = /^[0-9a-f]{64}$/; const HEAD = /^[0-9a-f]{40}$/; const ATTEMPT = /^[a-z0-9_.-]+\/[a-z0-9_.-]+:\d+:\d+$/; +const RECOVERED_LINEAGE_LIMIT = 16; function exactTime(value) { if (typeof value !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(value)) return false; @@ -24,6 +25,17 @@ function validState(state, repository, prNumber, { allowLegacyHead = false } = { (HEAD.test(state.head_sha) || (allowLegacyHead && state.head_sha === undefined)) && Number.isSafeInteger(state.revision) && state.revision >= 1 && ['available', 'prepared', 'consumed', 'confirmed'].includes(state.status) && + (state.released_generation == null || HEX.test(state.released_generation)) && + (state.released_generation_lineage == null || + (Array.isArray(state.released_generation_lineage) && + state.released_generation_lineage.every((generation) => HEX.test(generation)))) && + (state.recovered_generation == null || HEX.test(state.recovered_generation)) && + (state.recovered_generation_lineage == null || + (Array.isArray(state.recovered_generation_lineage) && + state.recovered_generation_lineage.length <= RECOVERED_LINEAGE_LIMIT && + state.recovered_generation_lineage.every((generation) => HEX.test(generation)))) && + (state.recovered_receipt == null || + (validReceipt(state.recovered_receipt) && HEX.test(state.recovered_generation))) && (state.status === 'available' ? state.receipt === null : validReceipt(state.receipt)); } @@ -43,6 +55,61 @@ function pathFor(repository, prNumber) { return `/repos/${String(repository).toLowerCase()}/contents/.github/keepalive-authority/${Number(prNumber)}.json`; } +function attemptPath(repository, ownerAttempt) { + if (!ATTEMPT.test(String(ownerAttempt)) || + !String(ownerAttempt).startsWith(`${String(repository).toLowerCase()}:`)) { + throw new Error('Invalid authority attempt repository'); + } + const key = crypto.createHash('sha256').update(ownerAttempt).digest('hex'); + return `/repos/${String(repository).toLowerCase()}/contents/.github/keepalive-authority-attempts/${key}.json`; +} + +function validAttemptIndex(index, repository, ownerAttempt) { + return index?.version === 1 && index.repository === String(repository).toLowerCase() && + index.owner_attempt === ownerAttempt && Number.isSafeInteger(index.pr_number) && + index.pr_number > 0 && HEX.test(index.generation) && validReceipt(index.receipt) && + index.receipt.owner_attempt === ownerAttempt; +} + +async function readAttemptIndex(request, repository, ownerAttempt, { allowMissing = false } = {}) { + let file; + try { + file = await request('GET', `${attemptPath(repository, ownerAttempt)}?ref=${BRANCH}`); + } catch (error) { + if (allowMissing && error.status === 404) return null; + throw error; + } + if (!/^[0-9a-f]{40}$/.test(String(file?.sha)) || file?.encoding !== 'base64') { + throw new Error('Invalid authority attempt index metadata'); + } + let index; + try { + index = JSON.parse(Buffer.from(String(file.content).replace(/\s/g, ''), 'base64').toString('utf8')); + } catch (error) { + throw new Error(`Malformed authority attempt index: ${error.message}`); + } + if (!validAttemptIndex(index, repository, ownerAttempt)) { + throw new Error('Invalid authority attempt index'); + } + return index; +} + +async function createAttemptIndex(request, repository, ownerAttempt, index) { + const existing = await readAttemptIndex(request, repository, ownerAttempt, { allowMissing: true }); + if (existing) return existing; + try { + await request('PUT', attemptPath(repository, ownerAttempt), { + branch: BRANCH, + message: `keepalive authority attempt ${ownerAttempt}`, + content: Buffer.from(`${JSON.stringify(index)}\n`).toString('base64'), + }); + return index; + } catch (error) { + if (![409, 422].includes(error.status)) throw error; + return readAttemptIndex(request, repository, ownerAttempt); + } +} + async function requestWithOctokit(github, method, path, body) { try { // Writes are conditional and intentionally get no automatic retry. An @@ -60,13 +127,18 @@ async function requestWithOctokit(github, method, path, body) { } function requester(github) { - if (!github) { - const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN; - if (!token) throw new Error('Authority state token unavailable'); + if (github) { + return (method, path, body) => requestWithOctokit(github, method, path, body); + } + const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN; + if (!token) throw new Error('Authority state token unavailable'); + try { const { Octokit } = require('@octokit/rest'); - github = new Octokit({ auth: token }); + const octokit = new Octokit({ auth: token }); + return (method, path, body) => requestWithOctokit(octokit, method, path, body); + } catch { + return createGithubFetchRequester({ token }); } - return (method, path, body) => requestWithOctokit(github, method, path, body); } async function ensureBranch(request, repository, defaultBranch) { @@ -123,22 +195,241 @@ async function writeAuthorityState(request, repository, prNumber, state, priorSh return request('PUT', pathFor(repository, prNumber), body); } -async function beginChallenge({ request, repository, prNumber, defaultBranch, fingerprint, dueAt, expiresAt, headSha, expectedGeneration = null }) { +function expectedGenerationMatches(state, expectedGeneration, headSha) { + if (!expectedGeneration) return true; + if (state?.generation === expectedGeneration) return true; + return state?.status === 'available' && state.receipt === null && + state.head_sha === headSha && ( + (state.released_receipt && + (state.released_generation === expectedGeneration || + state.released_generation_lineage?.includes(expectedGeneration))) || + (state.recovered_receipt && + (state.recovered_generation === expectedGeneration || + state.recovered_generation_lineage?.includes(expectedGeneration))) + ); +} + +function preparedAttemptMatchesIndex(state, index, repository, prNumber) { + const receipt = state?.receipt; + const claim = state?.prepared_claim; + return state?.status === 'prepared' && claim && receipt && + claim.generation === state.generation && + claim.boundary_fingerprint === state.boundary_fingerprint && + claim.due_at === state.due_at && claim.expires_at === state.expires_at && + claim.head_sha === state.head_sha && + receiptMatches(receipt, claim, receipt.owner_attempt, receipt.provider, state.head_sha) && + index.repository === String(repository).toLowerCase() && + index.pr_number === Number(prNumber) && index.owner_attempt === receipt.owner_attempt && + index.generation === state.generation && index.receipt.id === receipt.id && + index.receipt.claim_digest === receipt.claim_digest && + index.receipt.provider === receipt.provider && index.receipt.head_sha === state.head_sha; +} + +function legacyPreparedAttemptMatchesIndex(state, index, repository, prNumber) { + const receipt = state?.receipt; + const ownerAttempt = receipt?.owner_attempt; + return state?.status === 'prepared' && state.prepared_claim === undefined && + validReceipt(receipt) && receipt.head_sha === state.head_sha && + ownerAttempt.startsWith(`${String(repository).toLowerCase()}:`) && + index.repository === String(repository).toLowerCase() && + index.pr_number === Number(prNumber) && index.owner_attempt === ownerAttempt && + index.generation === state.generation && sameReceipt(index.receipt, receipt); +} + +function sameReceipt(left, right) { + return Boolean(left && right) && + ['id', 'claim_digest', 'owner_attempt', 'provider', 'head_sha', 'consumed_at'] + .every((field) => left[field] === right[field]); +} + +function recoveredAttemptMatchesIndex(state, index, repository, prNumber, ownerAttempt) { + return state?.status === 'available' && state.receipt === null && + validReceipt(state.recovered_receipt) && HEX.test(state.recovered_generation) && + index.repository === String(repository).toLowerCase() && + index.pr_number === Number(prNumber) && index.owner_attempt === ownerAttempt && + index.generation === state.recovered_generation && + sameReceipt(index.receipt, state.recovered_receipt) && + state.recovered_receipt.owner_attempt === ownerAttempt && + state.recovered_receipt.head_sha === state.head_sha; +} + +function nextRecoveredLineage(state) { + const original = state.recovered_generation || state.generation; + const unique = [...new Set([ + ...(state.recovered_generation_lineage || []), + original, + state.generation, + ])]; + const recent = unique.filter((generation) => generation !== original) + .slice(-(RECOVERED_LINEAGE_LIMIT - 1)); + return [original, ...recent]; +} + +async function recoverExpiredLegacyPreparation({ request, repository, prNumber, prior, now }) { + const state = prior.state; + if (state.status !== 'prepared' || state.prepared_claim !== undefined || + now.getTime() < Date.parse(state.expires_at) || + state.receipt?.head_sha !== state.head_sha || + !state.receipt?.owner_attempt?.startsWith(`${String(repository).toLowerCase()}:`)) { + return { outcome: 'preserve' }; + } + const eligible = () => prMatches(request, repository, prNumber, state.head_sha, + 'agent:needs-attention', 'needs-human').catch(() => false); + if (!await eligible()) return { outcome: 'preserve' }; + + const expectedIndex = { + version: 1, + repository: String(repository).toLowerCase(), + owner_attempt: state.receipt.owner_attempt, + pr_number: Number(prNumber), + generation: state.generation, + receipt: state.receipt, + }; + let index; + try { + index = await readAttemptIndex(request, repository, state.receipt.owner_attempt, + { allowMissing: true }); + if (!index) { + try { + index = await createAttemptIndex(request, repository, state.receipt.owner_attempt, + expectedIndex); + } catch (_) { + index = await readAttemptIndex(request, repository, state.receipt.owner_attempt, + { allowMissing: true }); + } + } + } catch (_) { + return { outcome: 'preserve' }; + } + if (!index || !legacyPreparedAttemptMatchesIndex(state, index, repository, prNumber)) { + return { outcome: 'preserve' }; + } + + const current = await readAuthorityState(request, repository, prNumber).catch(() => null); + if (!current || !legacyPreparedAttemptMatchesIndex( + current.state, index, repository, prNumber, + ) || current.state.generation !== state.generation || + !sameReceipt(current.state.receipt, state.receipt) || !await eligible()) { + return { outcome: 'preserve' }; + } + const nowMs = now.getTime(); + const next = { + ...current.state, + generation: crypto.randomBytes(32).toString('hex'), + due_at: new Date(nowMs).toISOString(), + expires_at: new Date(nowMs + 24 * 60 * 60 * 1000).toISOString(), + status: 'available', + receipt: null, + prepared_claim: null, + released_receipt: current.state.receipt, + released_generation: current.state.generation, + released_generation_lineage: [current.state.generation], + revision: current.state.revision + 1, + }; + try { + await writeAuthorityState(request, repository, prNumber, next, current.sha); + } catch (_) { + const settled = await readAuthorityState(request, repository, prNumber).catch(() => null); + const exactRelease = settled?.state.status === 'available' && + settled.state.receipt === null && settled.state.generation === next.generation && + settled.state.head_sha === state.head_sha && + settled.state.released_generation === state.generation && + sameReceipt(settled.state.released_receipt, state.receipt) && + settled.state.revision === next.revision; + if (!exactRelease) { + return { outcome: settled?.sha !== current.sha ? 'retry' : 'preserve' }; + } + } + if (!await eligible()) return { outcome: 'preserve' }; + return { outcome: 'recovered' }; +} + +async function recoverExpiredPreparation({ request, repository, prNumber, prior, now }) { + const state = prior.state; + if (state.status !== 'prepared' || now.getTime() < Date.parse(state.expires_at)) { + return { outcome: 'preserve' }; + } + if (state.prepared_claim === undefined) { + return recoverExpiredLegacyPreparation({ request, repository, prNumber, prior, now }); + } + let index; + try { + index = await readAttemptIndex(request, repository, state.receipt?.owner_attempt); + } catch (_) { + return { outcome: 'preserve' }; + } + if (!preparedAttemptMatchesIndex(state, index, repository, prNumber) || + !await prMatches(request, repository, prNumber, state.head_sha, + 'agent:needs-attention', 'needs-human').catch(() => false)) { + return { outcome: 'preserve' }; + } + const released = await releasePreparedChallenge({ + request, repository, prNumber, claim: state.prepared_claim, + ownerAttempt: state.receipt.owner_attempt, provider: state.receipt.provider, + headSha: state.head_sha, now, + }); + if (!released.released) { + return { outcome: ['challenge-conflict', 'challenge-preparation-not-current'] + .includes(released.reason) ? 'retry' : 'preserve' }; + } + const settled = await readAuthorityState(request, repository, prNumber).catch(() => null); + const exactRelease = settled?.state.status === 'available' && settled.state.receipt === null && + settled.state.head_sha === state.head_sha && + settled.state.released_generation === state.generation && + settled.state.released_receipt?.id === state.receipt.id && + settled.state.released_receipt?.owner_attempt === state.receipt.owner_attempt; + if (!exactRelease || !await prMatches(request, repository, prNumber, state.head_sha, + 'agent:needs-attention', 'needs-human').catch(() => false)) { + return { outcome: 'preserve' }; + } + return { outcome: 'recovered' }; +} + +async function beginChallenge({ request, repository, prNumber, defaultBranch, fingerprint, dueAt, expiresAt, headSha, expectedGeneration = null, now = new Date() }) { if (!HEX.test(String(fingerprint)) || !HEAD.test(String(headSha)) || !exactTime(dueAt) || !exactTime(expiresAt) || - Date.parse(expiresAt) <= Date.parse(dueAt)) throw new Error('Invalid challenge boundary'); + Date.parse(expiresAt) <= Date.parse(dueAt) || + !(now instanceof Date) || !Number.isFinite(now.getTime())) { + throw new Error('Invalid challenge boundary'); + } await ensureBranch(request, repository, defaultBranch); for (let attempt = 0; attempt < 3; attempt += 1) { const prior = await readAuthorityState(request, repository, prNumber, { allowMissing: true }); - if (expectedGeneration && (!prior || prior.state.generation !== expectedGeneration)) { + if (expectedGeneration && (!prior || + !expectedGenerationMatches(prior.state, expectedGeneration, headSha))) { throw new Error('Previously initialized challenge generation is missing or superseded'); } + // Consumed and confirmed receipts remain spent on expiry. A prepared receipt + // is non-authorizing and can be reaped only after its exact immutable attempt + // index and current PR routing state have both been verified. + if (prior && prior.state.head_sha === headSha && prior.state.status !== 'available') { + if (prior.state.status === 'prepared' && + now.getTime() >= Date.parse(prior.state.expires_at)) { + const recovery = await recoverExpiredPreparation({ + request, repository, prNumber, prior, now, + }); + if (['recovered', 'retry'].includes(recovery.outcome)) continue; + } + return prior.state; + } if (prior && prior.state.boundary_fingerprint === fingerprint && prior.state.head_sha === headSha) { - if (Date.parse(prior.state.expires_at) > Date.now()) { + if (Date.parse(prior.state.expires_at) > now.getTime()) { return prior.state; } } + const releasedState = prior?.state.head_sha === headSha && + prior.state.status === 'available' && prior.state.released_receipt + ? { + released_receipt: prior.state.released_receipt, + released_generation: prior.state.released_generation || prior.state.generation, + released_generation_lineage: [...new Set([ + ...(prior.state.released_generation_lineage || []), + prior.state.released_generation || prior.state.generation, + prior.state.generation, + ])], + } + : {}; const state = { version: 2, repository: String(repository).toLowerCase(), @@ -151,6 +442,7 @@ async function beginChallenge({ request, repository, prNumber, defaultBranch, fi status: 'available', receipt: null, revision: (prior?.state.revision || 0) + 1, + ...releasedState, }; try { await writeAuthorityState(request, repository, prNumber, state, prior?.sha); @@ -178,6 +470,10 @@ async function prepareChallenge({ request, repository, prNumber, claim, ownerAtt !HEAD.test(String(headSha)) || claim.head_sha !== headSha) { return { prepared: false, reason: 'challenge-not-current' }; } + if (prior.state.released_receipt?.owner_attempt === ownerAttempt || + prior.state.recovered_receipt?.owner_attempt === ownerAttempt) { + return { prepared: false, reason: 'attempt-already-settled' }; + } const receipt = { id: crypto.randomBytes(32).toString('hex'), claim_digest: crypto.createHash('sha256').update(JSON.stringify(claim)).digest('hex'), @@ -186,13 +482,31 @@ async function prepareChallenge({ request, repository, prNumber, claim, ownerAtt head_sha: headSha, consumed_at: now.toISOString(), }; - const next = { ...prior.state, status: 'prepared', receipt, revision: prior.state.revision + 1 }; + const candidate = { version: 1, repository: String(repository).toLowerCase(), + owner_attempt: ownerAttempt, pr_number: Number(prNumber), + generation: prior.state.generation, receipt }; + let index; + try { + index = await createAttemptIndex(request, repository, ownerAttempt, candidate); + } catch (_) { + return { prepared: false, reason: 'attempt-index-uncertain' }; + } + if (index.pr_number !== Number(prNumber) || index.generation !== prior.state.generation || + index.receipt.claim_digest !== receipt.claim_digest || + index.receipt.provider !== provider || index.receipt.head_sha !== headSha) { + return { prepared: false, reason: 'attempt-index-conflict' }; + } + const next = { ...prior.state, status: 'prepared', receipt: index.receipt, + prepared_claim: claim, recovered_receipt: null, recovered_generation: null, + recovered_generation_lineage: [], + released_receipt: null, released_generation: null, released_generation_lineage: [], + revision: prior.state.revision + 1 }; try { await writeAuthorityState(request, repository, prNumber, next, prior.sha); } catch (error) { return { prepared: false, reason: [409, 422].includes(error.status) ? 'challenge-conflict' : 'challenge-write-uncertain' }; } - return { prepared: true, reason: 'challenge-prepared', receipt }; + return { prepared: true, reason: 'challenge-prepared', receipt: index.receipt }; } function receiptMatches(receipt, claim, ownerAttempt, provider, headSha) { @@ -209,7 +523,27 @@ async function finalizeChallenge({ request, repository, prNumber, claim, ownerAt !receiptMatches(prior.state.receipt, claim, ownerAttempt, provider, headSha)) { return { granted: false, reason: 'challenge-preparation-not-current' }; } - const next = { ...prior.state, status: 'consumed', revision: prior.state.revision + 1 }; + let index; + try { + index = await readAttemptIndex(request, repository, ownerAttempt); + } catch (_) { + return { granted: false, reason: 'attempt-index-unavailable' }; + } + if (index.pr_number !== Number(prNumber) || index.generation !== prior.state.generation || + index.receipt.id !== prior.state.receipt.id || + index.receipt.claim_digest !== prior.state.receipt.claim_digest || + index.receipt.provider !== provider || index.receipt.head_sha !== headSha) { + return { granted: false, reason: 'attempt-index-conflict' }; + } + // Persist the exact claim beside its receipt. A workflow_run reporter may need to + // reopen this reservation after the owning run fails before an agent starts, and + // the nonce/sweep identity cannot be reconstructed from presentation state. + const next = { + ...prior.state, + status: 'consumed', + consumed_claim: claim, + revision: prior.state.revision + 1, + }; try { await writeAuthorityState(request, repository, prNumber, next, prior.sha); } catch (error) { @@ -224,22 +558,175 @@ async function finalizeChallenge({ request, repository, prNumber, claim, ownerAt return { granted: true, reason: 'due-authority-challenge', receipt: prior.state.receipt }; } -async function releasePreparedChallenge({ request, repository, prNumber, claim, ownerAttempt, provider, headSha }) { +async function releasePreparedChallenge({ request, repository, prNumber, claim, ownerAttempt, provider, headSha, + now = new Date() }) { + if (!(now instanceof Date) || !Number.isFinite(now.getTime())) { + return { released: false, reason: 'challenge-time-invalid' }; + } const prior = await readAuthorityState(request, repository, prNumber); + if (prior.state.status === 'available' && prior.state.receipt === null && + prior.state.head_sha === headSha && + (prior.state.released_generation || prior.state.generation) === claim.generation && + receiptMatches(prior.state.released_receipt, claim, ownerAttempt, provider, headSha)) { + // The conditional PUT may have committed even when its response was lost. + // An exact retry is settled; a newer preparation is never refunded. + if (now.getTime() >= Date.parse(prior.state.expires_at)) { + const refreshed = await refreshReleasedChallenge({ + request, repository, prNumber, ownerAttempt, prior, now, + }); + return { released: refreshed.status === 'released', reason: refreshed.reason }; + } + return { released: true, reason: 'challenge-preparation-already-released' }; + } if (prior.state.status !== 'prepared' || prior.state.head_sha !== headSha || prior.state.generation !== claim.generation || !receiptMatches(prior.state.receipt, claim, ownerAttempt, provider, headSha)) { return { released: false, reason: 'challenge-preparation-not-current' }; } - const next = { ...prior.state, status: 'available', receipt: null, revision: prior.state.revision + 1 }; + const expired = now.getTime() >= Date.parse(prior.state.expires_at); + if (expired && !await prMatches(request, repository, prNumber, headSha, + 'agent:needs-attention', 'needs-human').catch(() => false)) { + return { released: false, reason: 'challenge-pr-state-unavailable' }; + } + const nowMs = now.getTime(); + const next = { ...prior.state, status: 'available', receipt: null, + prepared_claim: null, released_receipt: prior.state.receipt, + released_generation: claim.generation, + released_generation_lineage: [claim.generation], + ...(expired ? { generation: crypto.randomBytes(32).toString('hex'), + due_at: new Date(nowMs).toISOString(), + expires_at: new Date(nowMs + 24 * 60 * 60 * 1000).toISOString() } : {}), + revision: prior.state.revision + 1 }; try { await writeAuthorityState(request, repository, prNumber, next, prior.sha); - return { released: true, reason: 'challenge-preparation-released' }; + return { released: true, reason: expired ? 'challenge-preparation-released-refreshed' : + 'challenge-preparation-released' }; } catch (error) { - return { released: false, reason: [409, 422].includes(error.status) ? 'challenge-conflict' : 'challenge-write-uncertain' }; + const settled = await readAuthorityState(request, repository, prNumber).catch(() => null); + if (settled?.state.status === 'available' && settled.state.generation === next.generation && + settled.state.released_generation === claim.generation && + settled.state.released_receipt?.id === prior.state.receipt.id) { + return { released: true, reason: 'challenge-preparation-already-released' }; + } + return { released: false, reason: [409, 422].includes(error.status) ? + 'challenge-conflict' : 'challenge-write-uncertain' }; } } +async function refreshReleasedChallenge({ request, repository, prNumber, ownerAttempt, prior = null, + now = new Date() }) { + if (!(now instanceof Date) || !Number.isFinite(now.getTime())) { + return { status: 'uncertain', reason: 'challenge-time-invalid' }; + } + const current = prior || await readAuthorityState(request, repository, prNumber); + const state = current.state; + if (state.status !== 'available' || state.receipt !== null || + state.released_receipt?.owner_attempt !== ownerAttempt) { + return { status: 'uncertain', reason: 'released-attempt-not-current' }; + } + const target = await findAuthorityPrForAttempt({ request, repository, ownerAttempt }).catch(() => null); + if (target?.prNumber !== Number(prNumber)) { + return { status: 'uncertain', reason: 'released-attempt-index-unavailable' }; + } + if (!await prMatches(request, repository, prNumber, state.head_sha, + 'agent:needs-attention', 'needs-human').catch(() => false)) { + return { status: 'uncertain', reason: 'challenge-pr-state-unavailable' }; + } + if (now.getTime() < Date.parse(state.expires_at)) { + return { status: 'released', reason: 'already-current', state, + previousGenerations: state.released_generation_lineage || + [state.released_generation || state.generation] }; + } + const nowMs = now.getTime(); + const lineage = [...new Set([...(state.released_generation_lineage || []), + state.released_generation || state.generation, state.generation])]; + const next = { ...state, generation: crypto.randomBytes(32).toString('hex'), + due_at: new Date(nowMs).toISOString(), + expires_at: new Date(nowMs + 24 * 60 * 60 * 1000).toISOString(), + released_generation: state.released_generation || state.generation, + released_generation_lineage: lineage, revision: state.revision + 1 }; + try { + await writeAuthorityState(request, repository, prNumber, next, current.sha); + return { status: 'released', reason: 'released-window-refreshed', state: next, + previousGenerations: lineage }; + } catch (_) { + const settled = await readAuthorityState(request, repository, prNumber).catch(() => null); + if (settled?.state.status === 'available' && settled.state.receipt === null && + settled.state.released_receipt?.id === state.released_receipt.id && + settled.state.generation === next.generation) { + return { status: 'released', reason: 'released-window-refreshed', state: settled.state, + previousGenerations: settled.state.released_generation_lineage }; + } + return { status: 'uncertain', reason: 'released-window-write-uncertain' }; + } +} + +async function refreshRecoveredChallenge({ request, repository, prNumber, ownerAttempt, prior = null, + now = new Date() }) { + if (!(now instanceof Date) || !Number.isFinite(now.getTime())) { + return { status: 'uncertain', reason: 'challenge-time-invalid' }; + } + const current = prior || await readAuthorityState(request, repository, prNumber); + const state = current.state; + if (state.status !== 'available' || state.receipt !== null || + state.recovered_receipt?.owner_attempt !== ownerAttempt || + !HEX.test(state.recovered_generation)) { + return { status: 'uncertain', reason: 'recovered-attempt-not-current' }; + } + let index; + try { + index = await readAttemptIndex(request, repository, ownerAttempt); + } catch (_) { + return { status: 'uncertain', reason: 'recovered-attempt-index-unavailable' }; + } + if (!recoveredAttemptMatchesIndex(state, index, repository, prNumber, ownerAttempt)) { + return { status: 'uncertain', reason: 'recovered-attempt-index-unavailable' }; + } + const eligible = () => prMatches(request, repository, prNumber, state.head_sha, + 'agent:needs-attention', 'needs-human').catch(() => false); + if (!await eligible()) { + return { status: 'uncertain', reason: 'challenge-pr-state-unavailable' }; + } + const previousGenerations = state.recovered_generation_lineage || + [state.recovered_generation]; + if (now.getTime() < Date.parse(state.expires_at)) { + return { status: 'reopened', reason: 'already-current', state, + previousGeneration: state.recovered_generation, previousGenerations }; + } + const nowMs = now.getTime(); + const lineage = nextRecoveredLineage(state); + const next = { ...state, generation: crypto.randomBytes(32).toString('hex'), + due_at: new Date(nowMs).toISOString(), + expires_at: new Date(nowMs + 24 * 60 * 60 * 1000).toISOString(), + recovered_generation: state.recovered_generation, + recovered_generation_lineage: lineage, + revision: state.revision + 1 }; + let settledState = next; + try { + await writeAuthorityState(request, repository, prNumber, next, current.sha); + } catch (_) { + const settled = await readAuthorityState(request, repository, prNumber).catch(() => null); + const exactRefresh = settled?.state.status === 'available' && settled.state.receipt === null && + settled.state.generation === next.generation && + settled.state.head_sha === state.head_sha && + settled.state.boundary_fingerprint === state.boundary_fingerprint && + settled.state.due_at === next.due_at && settled.state.expires_at === next.expires_at && + settled.state.revision === next.revision && + settled.state.recovered_generation === state.recovered_generation && + sameReceipt(settled.state.recovered_receipt, state.recovered_receipt) && + JSON.stringify(settled.state.recovered_generation_lineage) === JSON.stringify(lineage); + if (!exactRefresh) { + return { status: 'uncertain', reason: 'recovered-window-write-uncertain' }; + } + settledState = settled.state; + } + if (!await eligible()) { + return { status: 'uncertain', reason: 'challenge-pr-state-unavailable' }; + } + return { status: 'reopened', reason: 'recovered-window-refreshed', state: settledState, + previousGeneration: state.recovered_generation, previousGenerations: lineage }; +} + async function consumeChallenge(options) { const prepared = await prepareChallenge(options); if (!prepared.prepared) return { granted: false, reason: prepared.reason }; @@ -291,20 +778,27 @@ async function confirmChallenge({ request, repository, prNumber, claim, ownerAtt } } -async function reopenUnconfirmedChallenge({ request, repository, prNumber, claim, ownerAttempt, provider, headSha }) { +async function reopenUnconfirmedChallenge({ request, repository, prNumber, claim, ownerAttempt, provider, headSha, + workerEvidence = 'unknown' }) { const prior = await readAuthorityState(request, repository, prNumber); if (prior.state.head_sha !== headSha) return { status: 'uncertain', state: prior.state }; const receipt = prior.state.receipt; - const matches = prior.state.generation === claim.generation && - prior.state.boundary_fingerprint === claim.boundary_fingerprint && - receipt?.claim_digest === crypto.createHash('sha256').update(JSON.stringify(claim)).digest('hex') && + const recoveryClaim = claim && typeof claim === 'object' ? claim : prior.state.consumed_claim; + const matches = recoveryClaim && + prior.state.generation === recoveryClaim.generation && + prior.state.boundary_fingerprint === recoveryClaim.boundary_fingerprint && + receipt?.claim_digest === + crypto.createHash('sha256').update(JSON.stringify(recoveryClaim)).digest('hex') && receipt.owner_attempt === ownerAttempt && receipt.provider === provider && receipt.head_sha === headSha; const pr = await readPrState(request, repository, prNumber); if (!pr.open || pr.headSha !== headSha) return { status: 'uncertain', state: prior.state }; if (matches && pr.labels.has('needs-human')) { return { status: prior.state.status === 'confirmed' ? 'confirmed' : 'uncertain', state: prior.state }; } - if (!matches || !['consumed', 'confirmed'].includes(prior.state.status)) { + // A confirmed receipt has already crossed the hard-human boundary. An + // absent label (or a stale PR read) must never rotate it into fresh grant + // authority; only an unconfirmed consumed receipt is recoverable here. + if (!matches || prior.state.status !== 'consumed' || workerEvidence !== 'not-started') { return { status: 'uncertain', state: prior.state }; } const now = Date.now(); @@ -313,14 +807,18 @@ async function reopenUnconfirmedChallenge({ request, repository, prNumber, claim generation: crypto.randomBytes(32).toString('hex'), due_at: new Date(now).toISOString(), expires_at: new Date(now + 24 * 60 * 60 * 1000).toISOString(), - status: 'available', receipt: null, revision: prior.state.revision + 1, + status: 'available', receipt: null, prepared_claim: null, consumed_claim: null, + recovered_receipt: receipt, recovered_generation: prior.state.generation, + recovered_generation_lineage: [prior.state.generation], + revision: prior.state.revision + 1, }; try { await writeAuthorityState(request, repository, prNumber, state, prior.sha); return { status: 'reopened', state }; } catch (_) { const settled = await readAuthorityState(request, repository, prNumber).catch(() => null); - if (settled?.state.status === 'confirmed' && settled.state.generation === claim.generation && + if (recoveryClaim && settled?.state.status === 'confirmed' && + settled.state.generation === recoveryClaim.generation && settled.state.receipt?.id === receipt.id && await prMatches(request, repository, prNumber, headSha, 'needs-human')) { return { status: 'confirmed', state: settled.state }; @@ -332,7 +830,82 @@ async function reopenUnconfirmedChallenge({ request, repository, prNumber, claim } } +async function authorityAttemptOwnsRecoveryReceipt({ + request, + repository, + prNumber, + ownerAttempt, +}) { + const normalized = String(ownerAttempt || '').toLowerCase(); + if (!normalized || !/^[a-z0-9_.-]+\/[a-z0-9_.-]+:\d+:\d+$/.test(normalized)) { + return false; + } + const { state } = await readAuthorityState(request, repository, prNumber); + const receipts = [state.receipt, state.released_receipt, state.recovered_receipt].filter(Boolean); + return receipts.some((receipt) => receipt.owner_attempt === normalized && + receipt.head_sha === state.head_sha && + Boolean(receipt.provider)); +} + +async function reconcileFailedAuthorityAttempt({ request, repository, prNumber, ownerAttempt, workerEvidence }) { + if (workerEvidence !== 'not-started') return { status: 'execution-not-disproved' }; + const { state } = await readAuthorityState(request, repository, prNumber); + if (state.status === 'available' && state.recovered_receipt?.owner_attempt === ownerAttempt) { + return refreshRecoveredChallenge({ request, repository, prNumber, ownerAttempt }); + } + if (state.status === 'available' && state.released_receipt?.owner_attempt === ownerAttempt) { + return refreshReleasedChallenge({ request, repository, prNumber, ownerAttempt }); + } + const receipt = state.receipt; + if (!receipt || receipt.owner_attempt !== ownerAttempt || + receipt.head_sha !== state.head_sha || !receipt.provider) { + return { status: 'attempt-not-current' }; + } + const claim = state.status === 'prepared' ? state.prepared_claim : state.consumed_claim; + if (!claim || claim.head_sha !== state.head_sha || + !receiptMatches(receipt, claim, ownerAttempt, receipt.provider, state.head_sha)) { + return { status: 'claim-not-current' }; + } + const options = { request, repository, prNumber, claim, ownerAttempt, + provider: receipt.provider, headSha: state.head_sha, workerEvidence }; + if (state.status === 'prepared') { + const result = await releasePreparedChallenge(options); + if (!result.released) return { status: 'uncertain', reason: result.reason }; + const settled = await readAuthorityState(request, repository, prNumber); + if (settled.state.status !== 'available' || + !receiptMatches(settled.state.released_receipt, claim, ownerAttempt, + receipt.provider, state.head_sha)) return { status: 'uncertain' }; + if (!await prMatches(request, repository, prNumber, state.head_sha, + 'agent:needs-attention', 'needs-human').catch(() => false)) { + return { status: 'uncertain', reason: 'challenge-pr-state-unavailable' }; + } + return { status: 'released', reason: result.reason, state: settled.state, + previousGenerations: settled.state.released_generation_lineage || [claim.generation] }; + } + if (state.status === 'consumed') { + const result = await reopenUnconfirmedChallenge(options); + return { ...result, previousGeneration: state.generation }; + } + return { status: 'receipt-not-recoverable' }; +} + +async function findAuthorityPrForAttempt({ request, repository, ownerAttempt }) { + const index = await readAttemptIndex(request, repository, ownerAttempt, { allowMissing: true }); + if (!index) return null; + const { state } = await readAuthorityState(request, repository, index.pr_number); + const receipts = [state.receipt, state.released_receipt, state.recovered_receipt]; + if (!receipts.some((receipt) => receipt?.id === index.receipt.id && + receipt.owner_attempt === ownerAttempt && receipt.claim_digest === index.receipt.claim_digest && + receipt.head_sha === index.receipt.head_sha && receipt.provider === index.receipt.provider)) { + throw new Error('Authority attempt index does not match PR ledger receipt'); + } + return { prNumber: index.pr_number, state }; +} + module.exports = { + authorityAttemptOwnsRecoveryReceipt, + findAuthorityPrForAttempt, + reconcileFailedAuthorityAttempt, BRANCH, beginChallenge, claimMatchesState, diff --git a/.github/scripts/keepalive_loop.js b/.github/scripts/keepalive_loop.js index 0ee2d5a8..7344a0e3 100644 --- a/.github/scripts/keepalive_loop.js +++ b/.github/scripts/keepalive_loop.js @@ -18,7 +18,13 @@ const { detectConflicts } = require('./conflict_detector'); const { parseTimeoutConfig } = require('./timeout_config'); const { ensureRateLimitWrapped } = require('./github-rate-limited-wrapper'); const { verifyAuthorityChallengeClaim } = require('./keepalive_challenge_due'); -const { beginChallenge, confirmChallenge, reopenUnconfirmedChallenge, requester } = require('./keepalive_authority_state'); +const { + beginChallenge, + confirmChallenge, + reopenUnconfirmedChallenge, + authorityAttemptOwnsRecoveryReceipt, + requester, +} = require('./keepalive_authority_state'); // Token load balancer for rate limit management let tokenLoadBalancer = null; @@ -3314,6 +3320,9 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in agentExecutionStartedInput === undefined || agentExecutionStartedInput === '' ? null : toBool(agentExecutionStartedInput, false); + const authorityOwnerAttempt = normalise( + inputs.authority_owner_attempt ?? inputs.authorityOwnerAttempt, + ); const stateTrace = normalise(inputs.trace || inputs.keepalive_trace || ''); // Delegation policy inputs (from evaluate step when agent:auto is active) @@ -4237,28 +4246,6 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in '', '_To resume immediately: Wait for rate limit reset, or add additional API tokens._', ); - } else if (stop) { - const challengeDue = escalationDisposition === 'challenge-due'; - summaryLines.push( - '', - challengeDue - ? '### 🔎 Paused – Independent Authority Challenge Required' - : '### 🔁 Paused – Automation Recovery Required', - '', - challengeDue - ? 'The keepalive loop found a possible access boundary. Automation must verify it before asking a human.' - : 'The keepalive loop paused this execution strategy after repeated failures; ownership remains with automation.', - '', - '**To resume:**', - challengeDue - ? '1. Reproduce the access failure from current state and verify the exact unavailable permission or secret' - : '1. Route the failure to CI repair, retry/backoff, alternate-agent, review fallback, or issue decomposition', - '2. Record a concrete next action and responsible automation worker', - '3. Use `needs-human` only after an independent review proves a real authority boundary', - '4. Re-run Gate or apply the automation retry path', - '', - '_Or manually edit this comment to reset `failure: {}` in the state below._', - ); } const focusTask = currentFocus || fallbackFocus; @@ -4544,7 +4531,10 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in ['automation-retry', 'challenge-due'].includes(previousAttention.disposition)) || previousAttentionHasLegacyOwnership; let challengeState = null; + const mayConsumeAuthorityLedger = + agentExecutionStarted === null || agentExecutionStarted === true; if (shouldEscalate && escalationDisposition === 'challenge-due' && authorityEvidence.fingerprint) { + if (mayConsumeAuthorityLedger) { try { const repository = `${context.repo.owner}/${context.repo.repo}`; const request = requester(github); @@ -4567,11 +4557,64 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in core?.warning?.(`Authority generation unavailable: ${error.message}`); escalationDisposition = 'automation-retry'; } + } else if ( + previousAttention.disposition === 'challenge-due' && + previousAttention.generation && + previousAttention.boundary_fingerprint + ) { + try { + const repository = `${context.repo.owner}/${context.repo.repo}`; + const request = requester(github); + const claim = previousAttention.nonce && previousAttention.sweep_run_id && + previousAttention.sweep_run_attempt + ? { + generation: previousAttention.generation, + boundary_fingerprint: previousAttention.boundary_fingerprint, + due_at: previousAttention.challenge_due_at, + expires_at: previousAttention.expires_at, + head_sha: inputs.head_sha ?? inputs.headSha, + nonce: previousAttention.nonce, + sweep_run_id: previousAttention.sweep_run_id, + sweep_run_attempt: previousAttention.sweep_run_attempt, + } + : null; + const recovery = await reopenUnconfirmedChallenge({ + request, + repository, + prNumber, + claim, + ownerAttempt: authorityOwnerAttempt || + `${repository.toLowerCase()}:${context.runId || process.env.GITHUB_RUN_ID || ''}:` + + `${context.runAttempt || process.env.GITHUB_RUN_ATTEMPT || ''}`, + provider: agentType, + headSha: inputs.head_sha ?? inputs.headSha, + // Presentation outputs cannot prove that an exact worker attempt did not start. + // Only the attempt-bound workflow reporter may reopen a consumed receipt. + workerEvidence: 'unknown', + }); + if (recovery.status === 'reopened' && recovery.state) { + challengeState = { + status: 'available', + generation: recovery.state.generation, + due_at: recovery.state.due_at, + expires_at: recovery.state.expires_at, + }; + } else { + escalationDisposition = 'automation-retry'; + } + } catch (error) { + core?.warning?.(`Authority reopen after skipped execution unavailable: ${error.message}`); + escalationDisposition = 'automation-retry'; + } + } else { + escalationDisposition = 'automation-retry'; + } } if (escalationDisposition === 'challenge-due' && !challengeState) { escalationDisposition = 'automation-retry'; } const challengeDueAt = challengeState?.due_at || null; + let effectiveDisposition = escalationDisposition; if (shouldEscalate) { const firstSeenAt = priorAttentionKey === attentionKey ? previousAttention.first_seen_at || new Date().toISOString() @@ -4591,24 +4634,91 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in next_action: authorityEvidence.humanAction, }; } else { + const recoveryOwnerAttempt = String(authorityOwnerAttempt || + `${context.repo.owner}/${context.repo.repo}:` + + `${context.runId || process.env.GITHUB_RUN_ID || ''}:` + + `${context.runAttempt || process.env.GITHUB_RUN_ATTEMPT || ''}`).toLowerCase(); + const attemptBoundRecoveryCandidate = escalationDisposition === 'automation-retry' && + agentExecutionStarted === false && + previousAttention.disposition === 'challenge-due' && + previousAttention.generation && + /^[a-z0-9_.-]+\/[a-z0-9_.-]+:\d+:\d+$/.test(recoveryOwnerAttempt); + let attemptBoundRecoveryMarkers = {}; + if (attemptBoundRecoveryCandidate) { + try { + const repository = `${context.repo.owner}/${context.repo.repo}`.toLowerCase(); + const request = requester(github); + const ownsRecoveryReceipt = await authorityAttemptOwnsRecoveryReceipt({ + request, + repository, + prNumber, + ownerAttempt: recoveryOwnerAttempt, + }); + if (ownsRecoveryReceipt) { + attemptBoundRecoveryMarkers = { + recovery_generation: previousAttention.generation, + recovery_owner_attempt: recoveryOwnerAttempt, + }; + } + } catch (error) { + core?.warning?.( + `Authority recovery marker ownership check failed: ${error.message}`, + ); + } + } + // A later retry may write this summary before the original attempt's + // reporter projects its settled receipt. Keep that attempt's markers. + const pendingRecovery = escalationDisposition === 'automation-retry' && + previousAttention.owner === 'automation' && + previousAttention.disposition === 'automation-retry' && + /^[a-f0-9]{64}$/.test(previousAttention.recovery_generation || '') && + /^[a-z0-9_.-]+\/[a-z0-9_.-]+:\d+:\d+$/.test( + previousAttention.recovery_owner_attempt || ''); + const preservePendingChallenge = attemptBoundRecoveryCandidate && + previousAttention.disposition === 'challenge-due' && + /^[a-f0-9]{64}$/.test(previousAttention.generation || ''); + effectiveDisposition = preservePendingChallenge + ? 'challenge-due' + : escalationDisposition; newState.attention = { key: attentionKey, - disposition: escalationDisposition, + disposition: effectiveDisposition, owner: 'automation', first_seen_at: firstSeenAt, - challenge_due_at: challengeDueAt, - generation: challengeState?.generation || '', - expires_at: challengeState?.expires_at || '', - boundary_fingerprint: escalationDisposition === 'challenge-due' - ? authorityEvidence.fingerprint + challenge_due_at: preservePendingChallenge + ? previousAttention.challenge_due_at + : challengeDueAt, + generation: preservePendingChallenge + ? previousAttention.generation + : (challengeState?.generation || ''), + expires_at: preservePendingChallenge + ? previousAttention.expires_at + : (challengeState?.expires_at || ''), + boundary_fingerprint: effectiveDisposition === 'challenge-due' + ? (preservePendingChallenge + ? previousAttention.boundary_fingerprint + : authorityEvidence.fingerprint) : '', - boundary_detail: escalationDisposition === 'challenge-due' - ? authorityEvidence.detail + boundary_detail: effectiveDisposition === 'challenge-due' + ? (preservePendingChallenge + ? previousAttention.boundary_detail + : authorityEvidence.detail) : '', - next_action: escalationDisposition === 'challenge-due' + ...(Object.keys(attemptBoundRecoveryMarkers).length ? attemptBoundRecoveryMarkers : pendingRecovery ? { + recovery_generation: previousAttention.recovery_generation, + recovery_owner_attempt: previousAttention.recovery_owner_attempt, + } : {}), + next_action: effectiveDisposition === 'challenge-due' ? 'Independently rerun the current operation and confirm the same redacted authority-boundary fingerprint.' : 'Route to automation retry/backoff, CI repair, alternate agent, or review fallback.', }; + if (preservePendingChallenge && shouldIssueTerminalRecoveryLease) { + if (Object.keys(previousRecoveryLease).length) { + newState.recovery_lease = previousRecoveryLease; + } else { + delete newState.recovery_lease; + } + } } } @@ -4702,6 +4812,7 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in hardHumanLabelApplied = true; } catch (error) { escalationDisposition = 'challenge-due'; + effectiveDisposition = escalationDisposition; newState.attention = pendingState.attention; core?.warning?.(`Failed to apply needs-human; retaining durable authority challenge: ${error.message}`); } @@ -4735,6 +4846,7 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in ownerAttempt: `${repository.toLowerCase()}:${context.runId || process.env.GITHUB_RUN_ID || ''}:${context.runAttempt || process.env.GITHUB_RUN_ATTEMPT || ''}`, provider: agentType, headSha: inputs.head_sha ?? inputs.headSha, + workerEvidence: 'unknown', }); } catch (error) { core?.warning?.(`Authority confirmation reconciliation unavailable: ${error.message}`); @@ -4743,6 +4855,7 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in authorityChallengeConfirmed = true; } else { escalationDisposition = 'challenge-due'; + effectiveDisposition = escalationDisposition; newState.attention = { ...pendingAttention, generation: recovery.state?.generation || pendingAttention.generation, @@ -4770,6 +4883,34 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in } } + // Render terminal guidance only after receipt ownership and authority + // reconciliation have selected the final disposition. Rendering from the + // earlier escalation candidate can falsely advertise an ordinary retry + // while a consumed authority receipt is being preserved as challenge-due. + if (!isRateLimitExhausted && stop) { + const challengeDue = effectiveDisposition === 'challenge-due'; + summaryLines.push( + '', + challengeDue + ? '### 🔎 Paused – Independent Authority Challenge Required' + : '### 🔁 Paused – Automation Recovery Required', + '', + challengeDue + ? 'The keepalive loop found a possible access boundary. Automation must verify it before asking a human.' + : 'The keepalive loop paused this execution strategy after repeated failures; ownership remains with automation.', + '', + '**To resume:**', + challengeDue + ? '1. Reproduce the access failure from current state and verify the exact unavailable permission or secret' + : '1. Route the failure to CI repair, retry/backoff, alternate-agent, review fallback, or issue decomposition', + '2. Record a concrete next action and responsible automation worker', + '3. Use `needs-human` only after an independent review proves a real authority boundary', + '4. Re-run Gate or apply the automation retry path', + '', + '_Or manually edit this comment to reset `failure: {}` in the state below._', + ); + } + summaryLines.push('', formatStateComment(newState)); const body = summaryLines.join('\n'); await persistSummary(body); @@ -4821,9 +4962,9 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in } if (shouldEscalate) { - const routingLabel = escalationDisposition === 'needs-human' + const routingLabel = effectiveDisposition === 'needs-human' ? 'needs-human' - : escalationDisposition === 'challenge-due' + : effectiveDisposition === 'challenge-due' ? 'agent:needs-attention' : 'agent:retry'; const addRoutingLabel = () => github.rest.issues.addLabels({ @@ -4841,11 +4982,11 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in core, automationOwned: previousAttentionAutomationOwned, }); - if (escalationDisposition === 'needs-human') { + if (effectiveDisposition === 'needs-human') { // The hard blocker was applied before the human-owned state was // persisted. Only now may the recoverable label be removed. await clearAutomationAttention(); - } else if (escalationDisposition === 'challenge-due') { + } else if (effectiveDisposition === 'challenge-due') { // Adding an already-present label is idempotent. Never remove the // only sweep-routing signal while renewing or replacing a challenge. await addRoutingLabel(); @@ -4857,13 +4998,13 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in await clearAutomationAttention(); } } catch (error) { - if (core) core.warning(`Failed to add ${escalationDisposition} routing label: ${error.message}`); + if (core) core.warning(`Failed to add ${effectiveDisposition} routing label: ${error.message}`); } // Every automation-owned terminal gets one immediate recovery lease. // Persist the issued/consumed lease across events so later ordinary // sweeps cannot mint another lease for the same terminal boundary. if ( - escalationDisposition === 'automation-retry' && + effectiveDisposition === 'automation-retry' && !isForceRetry && (!stop || shouldIssueTerminalRecoveryLease) ) { @@ -5119,6 +5260,15 @@ async function markAgentRunning({ github: rawGithub, context, core, inputs }) { const preservedState = previousState || {}; preservedState.running = true; preservedState.running_since = new Date().toISOString(); + const runningRunId = context.runId || process.env.GITHUB_RUN_ID || ''; + if (runningRunId) { + preservedState.running_owner_attempt = ( + `${context.repo.owner}/${context.repo.repo}:${runningRunId}:` + + `${context.runAttempt || process.env.GITHUB_RUN_ATTEMPT || '1'}` + ).toLowerCase(); + } else { + delete preservedState.running_owner_attempt; + } if (suggestedFocus?.text) { preservedState.current_focus = suggestedFocus.text; preservedState.current_focus_set_at = new Date().toISOString(); diff --git a/.github/scripts/keepalive_reporter_applicability.js b/.github/scripts/keepalive_reporter_applicability.js new file mode 100644 index 00000000..cc99c774 --- /dev/null +++ b/.github/scripts/keepalive_reporter_applicability.js @@ -0,0 +1,236 @@ +'use strict'; + +const { + findAuthorityPrForAttempt, + readAuthorityState, + reconcileFailedAuthorityAttempt, + requester, +} = require('./keepalive_authority_state.js'); +const { loadKeepaliveState, projectRecoveredAuthorityState } = require('./keepalive_state.js'); +const { getWorkerExecutionEvidence } = require('./keepalive_worker_evidence.js'); +const { withRetry } = require('./github-api-with-retry.js'); + +const CONTRACT = /^run-name: \$\{\{ github\.event_name == 'workflow_dispatch' && format\('keepalive-dispatch\/v2 \{0\} pr=\{1\}', \(inputs\.authority_challenge_claim != '' \|\| inputs\.authority_challenge_fingerprint != ''\) && 'authority-candidate' \|\| 'ordinary', inputs\.pr_number\) \|\| 'Agents (?:Keepalive Loop|Gate Followups)' \}\}$/; +const TITLE_CONTRACT = /^keepalive-dispatch\/v2 (ordinary|authority-candidate) pr=([1-9][0-9]*)$/; +const PRODUCERS = new Set([ + '.github/workflows/agents-keepalive-loop.yml', + '.github/workflows/agents-81-gate-followups.yml', +]); + +async function classifyReporterRun({ github, owner, repo, run, lookupTarget = findAuthorityPrForAttempt }) { + if (Number(run.pull_requests?.[0]?.number || 0) > 0) return { status: 'continue' }; + const repository = `${owner}/${repo}`; + const ownerAttempt = `${repository}:${run.id}:${run.run_attempt || 1}`.toLowerCase(); + // The immutable index, if present, always wins over a display title. + const target = await lookupTarget({ + request: requester(github), repository, ownerAttempt, + }); + if (target) return { status: 'continue', prNumber: target.prNumber }; + + const { data: origin } = await withRetry((client) => client.rest.actions.getWorkflowRun({ + owner, repo, run_id: run.id, + }), { github, maxRetries: 2, task: 'keepalive-reporter-run' }); + if (Number(origin.id) !== Number(run.id) || + Number(origin.run_attempt) !== Number(run.run_attempt || 1) || + String(origin.head_sha) !== String(run.head_sha) || + origin.event !== 'workflow_dispatch' || !PRODUCERS.has(origin.path)) { + throw new Error('Unassociated run has no verified dispatch classification'); + } + const { data: producer } = await withRetry((client) => client.rest.repos.getContent({ + owner, repo, path: origin.path, ref: origin.head_sha, + }), { github, maxRetries: 2, task: 'keepalive-reporter-producer' }); + const producerText = producer.encoding === 'base64' + ? Buffer.from(String(producer.content).replace(/\s/g, ''), 'base64').toString('utf8') + : ''; + const producerLines = producerText.split(/\r?\n/); + if (!producerText || !CONTRACT.test(producerLines[1] || '') || + producerLines.filter((line) => CONTRACT.test(line)).length !== 1) { + throw new Error('Originating workflow revision lacks the dispatch classification contract'); + } + const title = String(origin.display_title || ''); + const titleMatch = TITLE_CONTRACT.exec(title); + if (!titleMatch) { + throw new Error('Unassociated dispatch has no canonical versioned PR binding'); + } + const classification = titleMatch[1]; + const prText = titleMatch[2]; + const prNumber = Number(prText); + if (!Number.isSafeInteger(prNumber) || prNumber <= 0 || String(prNumber) !== prText) { + throw new Error('Unassociated dispatch has a non-canonical PR binding'); + } + if (classification === 'authority-candidate') { + throw new Error('Authority-candidate dispatch has no immutable attempt index'); + } + return { status: 'continue', prNumber, targetSource: 'ordinary-run-name' }; +} + +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) { + const loaded = await loadKeepaliveState({ github, context, prNumber, trace: '' }); + if (loaded.state.running === true && loaded.state.running_owner_attempt && + loaded.state.running_owner_attempt !== ownerAttempt) { + return { status: 'superseded', prNumber, ownerAttempt, authorityTarget, + projection: { projected: false, reason: 'running-attempt-superseded' } }; + } + return { status: 'continue', prNumber, ownerAttempt, authorityTarget }; + } + throw error; + } + if (['released', 'reopened'].includes(reconciliation.status)) { + const projection = await projectRecovery({ + github, context, prNumber, recovery: reconciliation, writerLogin, + }); + if (projection.projected === false) { + return { + status: 'superseded', prNumber, ownerAttempt, authorityTarget, reconciliation, projection, + }; + } + return { + status: 'projected', prNumber, ownerAttempt, authorityTarget, reconciliation, projection, + }; + } + const loaded = await loadKeepaliveState({ github, context, prNumber, trace: '' }); + if (loaded.state.running === true && loaded.state.running_owner_attempt && + loaded.state.running_owner_attempt !== ownerAttempt) { + return { status: 'superseded', prNumber, ownerAttempt, authorityTarget, reconciliation, + projection: { projected: false, reason: 'running-attempt-superseded' } }; + } + return { status: 'continue', prNumber, ownerAttempt, authorityTarget, reconciliation }; +} + +function parseOwnerAttempt(repository, ownerAttempt) { + const normalized = String(ownerAttempt || '').toLowerCase(); + const prefix = `${String(repository || '').toLowerCase()}:`; + if (!normalized.startsWith(prefix)) return null; + const match = /:(\d+):(\d+)$/.exec(normalized); + if (!match) return null; + const runId = Number(match[1]); + const runAttempt = Number(match[2]); + if (!Number.isSafeInteger(runId) || runId <= 0 || + !Number.isSafeInteger(runAttempt) || runAttempt <= 0) return null; + return { ownerAttempt: normalized, runId, runAttempt }; +} + +async function replayReporterAuthority({ + github, + context, + prNumber, + writerLogin, + maxPasses = 3, + readAuthority = readAuthorityState, + lookupTarget = findAuthorityPrForAttempt, + reconcileAttempt = reconcileFailedAuthorityAttempt, + projectRecovery = projectRecoveredAuthorityState, + workerEvidenceForAttempt = getWorkerExecutionEvidence, + makeRequest = requester, +}) { + const owner = context.repo.owner; + const repo = context.repo.repo; + const repository = `${owner}/${repo}`.toLowerCase(); + const number = Number(prNumber); + if (!Number.isSafeInteger(number) || number <= 0) { + throw new Error('Replay requires a positive PR number'); + } + const request = makeRequest(github); + const results = []; + const seen = new Set(); + for (let pass = 0; pass < maxPasses; pass += 1) { + const { state } = await readAuthority(request, repository, number); + const attempts = [state.receipt, state.released_receipt, state.recovered_receipt] + .map((receipt) => parseOwnerAttempt(repository, receipt?.owner_attempt)) + .filter(Boolean) + .filter((attempt) => !seen.has(attempt.ownerAttempt)); + if (attempts.length === 0) break; + let changed = false; + for (const attempt of attempts) { + seen.add(attempt.ownerAttempt); + const indexed = await lookupTarget({ + request, repository, ownerAttempt: attempt.ownerAttempt, + }); + if (Number(indexed?.prNumber || 0) !== number) { + throw new Error(`Replay attempt ${attempt.ownerAttempt} is not indexed to PR #${number}`); + } + const runResponse = await github.request( + 'GET /repos/{owner}/{repo}/actions/runs/{run_id}/attempts/{attempt_number}', + { owner, repo, run_id: attempt.runId, attempt_number: attempt.runAttempt }, + ); + const run = runResponse?.data || {}; + if (run.status !== 'completed' || + Number(run.id) !== attempt.runId || + Number(run.run_attempt || 0) !== attempt.runAttempt || + !/^[0-9a-f]{40}$/.test(String(run.head_sha || ''))) { + throw new Error(`Replay run identity is unavailable for ${attempt.ownerAttempt}`); + } + const workerEvidence = await workerEvidenceForAttempt( + github, owner, repo, attempt.runId, attempt.runAttempt, run.head_sha, + ); + if (workerEvidence === 'unknown') { + throw new Error(`Replay worker evidence is unknown for ${attempt.ownerAttempt}`); + } + const reconciliation = await reconcileAttempt({ + request, repository, prNumber: number, + ownerAttempt: attempt.ownerAttempt, workerEvidence, + }); + let projection = null; + if (['released', 'reopened'].includes(reconciliation.status)) { + projection = await projectRecovery({ + github, context, prNumber: number, recovery: reconciliation, writerLogin, + }); + if (projection?.projected === false && + projection.reason !== 'recovery-superseded') { + throw new Error( + `Replay projection is unresolved for ${attempt.ownerAttempt}: ` + + `${projection.reason || 'unknown'}`, + ); + } + changed = changed || projection?.projected !== false; + } + results.push({ + ownerAttempt: attempt.ownerAttempt, workerEvidence, + status: reconciliation.status, projection, + }); + } + if (!changed) break; + } + return { prNumber: number, results }; +} + +module.exports = { + classifyReporterRun, + parseOwnerAttempt, + recoverReporterAuthority, + replayReporterAuthority, +}; diff --git a/.github/scripts/keepalive_state.js b/.github/scripts/keepalive_state.js index c2be9b2e..3d07f40b 100644 --- a/.github/scripts/keepalive_state.js +++ b/.github/scripts/keepalive_state.js @@ -1,6 +1,7 @@ 'use strict'; const { ensureRateLimitWrapped } = require('./github-rate-limited-wrapper.js'); +const { readAuthorityState, requester } = require('./keepalive_authority_state.js'); const STATE_MARKER = 'keepalive-state'; const STATE_VERSION = 'v1'; @@ -435,6 +436,139 @@ async function loadKeepaliveState({ github: rawGithub, context, prNumber, trace }; } +function summaryMatchesRecoveredAuthority(state, priorGenerations, recoveredAttempt) { + if (state?.attention && priorGenerations.has(state.attention.generation)) { + if (state.running === true) { + return Boolean(recoveredAttempt) && state.running_owner_attempt === recoveredAttempt; + } + return !state.running_owner_attempt || state.running_owner_attempt === recoveredAttempt; + } + const attention = state?.attention || {}; + return state?.running === false && + attention.owner === 'automation' && + attention.disposition === 'automation-retry' && + !attention.generation && + !attention.boundary_fingerprint && + !attention.challenge_due_at && + !attention.expires_at && + recoveredAttempt && + attention.recovery_owner_attempt === recoveredAttempt && + priorGenerations.has(attention.recovery_generation); +} + +const RECOVERY_RECEIPT_FIELDS = ['id', 'owner_attempt', 'claim_digest', 'head_sha', 'provider']; + +function sameRecoveryReceipt(left, right) { + return Boolean(left && right) && + RECOVERY_RECEIPT_FIELDS.every((field) => left[field] === right[field]); +} + +function currentSettledRecovery(recovery, current) { + const receiptField = recovery.status === 'released' ? 'released_receipt' : 'recovered_receipt'; + if (current?.status !== 'available' || + current.head_sha !== recovery.state.head_sha || + current.boundary_fingerprint !== recovery.state.boundary_fingerprint || + !sameRecoveryReceipt(current[receiptField], recovery.state[receiptField])) { + return null; + } + return current; +} + +async function projectRecoveredAuthorityState({ + github, context, prNumber, recovery, writerLogin, + readAuthority = readAuthorityState, makeRequest = requester, +}) { + if (!['released', 'reopened'].includes(recovery?.status) || !recovery.state) { + throw new Error('No settled authority recovery to project'); + } + const repository = `${context.repo.owner}/${context.repo.repo}`; + const firstAuthorityRead = await readAuthority(makeRequest(github), repository, prNumber); + let currentRecovery = currentSettledRecovery(recovery, firstAuthorityRead?.state); + if (!currentRecovery) { + return { projected: false, reason: 'recovery-superseded' }; + } + const currentGenerationLineage = recovery.status === 'released' + ? (currentRecovery.released_generation_lineage || []) + : (currentRecovery.recovered_generation_lineage || []); + const priorGenerations = new Set([ + recovery.previousGeneration || recovery.state.generation, + ...(recovery.previousGenerations || []), + recovery.state.generation, + currentRecovery.generation, + ...currentGenerationLineage, + ]); + const recoveredAttempt = (recovery.status === 'released' ? recovery.state.released_receipt : + recovery.state.recovered_receipt)?.owner_attempt || ''; + const loaded = await loadKeepaliveState({ github, context, prNumber, trace: '' }); + let state = loaded.state; + if (state?.running === false && recoveredAttempt && + state?.attention?.recovery_owner_attempt === recoveredAttempt && + state.attention.generation === currentRecovery.generation) { + return { projected: true, reason: 'already-projected' }; + } + if (!summaryMatchesRecoveredAuthority(state, priorGenerations, recoveredAttempt)) { + throw new Error('Trusted summary does not match the recovered generation'); + } + const sameWriter = loaded.commentId && + String(loaded.commentAuthorLogin || '').toLowerCase() === String(writerLogin || '').toLowerCase(); + if (sameWriter) { + const response = await github.rest.issues.getComment({ + owner: context.repo.owner, repo: context.repo.repo, comment_id: loaded.commentId, + }); + const latest = parseStateComment(response?.data?.body)?.data; + if (!summaryMatchesRecoveredAuthority(latest, priorGenerations, recoveredAttempt)) { + if (latest?.attention?.generation === currentRecovery.generation && latest.running === false && + latest.attention.recovery_owner_attempt === recoveredAttempt) { + return { projected: true, reason: 'already-projected' }; + } + throw new Error('Trusted summary changed during authority projection'); + } + state = latest; + } + const finalAuthorityRead = await readAuthority(makeRequest(github), repository, prNumber); + currentRecovery = currentSettledRecovery(recovery, finalAuthorityRead?.state); + if (!currentRecovery || (firstAuthorityRead?.sha && finalAuthorityRead?.sha !== firstAuthorityRead.sha)) { + return { projected: false, reason: 'recovery-superseded' }; + } + const { recovery_generation: _recoveryGeneration, ...priorAttention } = state.attention; + const projected = { + ...state, + running: false, + running_since: null, + running_owner_attempt: null, + attention: { + ...priorAttention, + owner: 'automation', disposition: 'challenge-due', + generation: currentRecovery.generation, + recovery_owner_attempt: recoveredAttempt, + boundary_fingerprint: currentRecovery.boundary_fingerprint, + challenge_due_at: currentRecovery.due_at, + expires_at: currentRecovery.expires_at, + next_action: 'Retry the exact-head authority challenge through the owning sweep.', + }, + }; + const body = [ + '', + '## Keepalive Loop Status', + '', + 'The failed originating run did not start a worker; its authority receipt was reconciled.', + '', + formatStateComment(projected), + ].join('\n'); + if (sameWriter) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, repo: context.repo.repo, + comment_id: loaded.commentId, body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, repo: context.repo.repo, + issue_number: prNumber, body, + }); + } + return { projected: true, reason: 'recovered-summary-projected' }; +} + async function resetState({ github: rawGithub, context, prNumber, trace, round }) { // Wrap github client with rate-limit-aware retry let github; @@ -527,6 +661,7 @@ async function resetState({ github: rawGithub, context, prNumber, trace, round } } module.exports = { + projectRecoveredAuthorityState, createKeepaliveStateManager, saveKeepaliveState, loadKeepaliveState, diff --git a/.github/scripts/keepalive_worker_evidence.js b/.github/scripts/keepalive_worker_evidence.js new file mode 100644 index 00000000..c7a36461 --- /dev/null +++ b/.github/scripts/keepalive_worker_evidence.js @@ -0,0 +1,67 @@ +'use strict'; + +const { withRetry } = require('./github-api-with-retry.js'); +const { loadAgentRegistry, parseRegistryYaml } = require('./agent_registry.js'); + +// Only an exact originating attempt can prove that its worker did not start. +// A missing or incomplete jobs response is unknown, never a safe refund. +function workerNames(registry) { + const agents = registry?.agents || {}; + return Object.entries(agents) + .filter(([, config]) => config?.capabilities?.pr_keepalive === true && config?.enabled !== false) + .map(([key, config]) => { + const title = key.charAt(0).toUpperCase() + key.slice(1); + return { + job: String(config.keepalive_worker_job || `Keepalive next task (${title})`), + step: String(config.keepalive_worker_step || `Run ${title}`), + }; + }); +} + +function classifyWorkerExecution(jobs, registry = loadAgentRegistry()) { + if (!Array.isArray(jobs)) return 'unknown'; + const names = workerNames(registry); + const workers = jobs.flatMap((job) => names + .filter(({ job: expected }) => String(job?.name || '') === expected || + String(job?.name || '').startsWith(`${expected} /`)) + .map(({ step }) => ({ job, step }))); + if (workers.length === 0) return 'unknown'; + for (const { job, step } of workers) { + if (job.status !== 'completed') return 'unknown'; + if (job.conclusion === 'skipped') continue; + const steps = job.steps; + if (!Array.isArray(steps)) return 'unknown'; + const workerStep = steps.find((item) => String(item?.name || '') === step); + if (!workerStep) return 'unknown'; + if (workerStep.status !== 'completed') return 'unknown'; + if (workerStep.conclusion !== 'skipped') return 'started'; + } + return 'not-started'; +} + +async function getWorkerExecutionEvidence(github, owner, repo, runId, runAttempt, runHeadSha) { + if (!Number.isInteger(Number(runId)) || Number(runId) <= 0 || + !Number.isInteger(Number(runAttempt)) || Number(runAttempt) <= 0 || + !/^[0-9a-f]{40}$/.test(String(runHeadSha || ''))) return 'unknown'; + try { + const registryResponse = await withRetry((client) => client.rest.repos.getContent({ + owner, repo, path: '.github/agents/registry.yml', ref: runHeadSha, + }), { github, maxRetries: 2, task: 'keepalive-origin-registry' }); + const registryFile = registryResponse?.data; + if (registryFile?.type !== 'file' || registryFile?.encoding !== 'base64') return 'unknown'; + const registry = parseRegistryYaml(Buffer.from( + String(registryFile.content || '').replace(/\s/g, ''), 'base64', + ).toString('utf8')); + if (!registry?.agents || typeof registry.agents !== 'object') return 'unknown'; + const jobs = await withRetry(() => github.paginate( + github.rest.actions.listJobsForWorkflowRunAttempt, { + owner, repo, + run_id: Number(runId), attempt_number: Number(runAttempt), per_page: 100, + }), { github, maxRetries: 2, task: 'keepalive-worker-evidence' }); + return classifyWorkerExecution(jobs, registry); + } catch (_) { + return 'unknown'; + } +} + +module.exports = { classifyWorkerExecution, getWorkerExecutionEvidence }; diff --git a/.github/workflows/agents-81-gate-followups.yml b/.github/workflows/agents-81-gate-followups.yml index 88e818e0..9e532f5e 100644 --- a/.github/workflows/agents-81-gate-followups.yml +++ b/.github/workflows/agents-81-gate-followups.yml @@ -1,4 +1,5 @@ name: Agents Gate Followups +run-name: ${{ github.event_name == 'workflow_dispatch' && format('keepalive-dispatch/v2 {0} pr={1}', (inputs.authority_challenge_claim != '' || inputs.authority_challenge_fingerprint != '') && 'authority-candidate' || 'ordinary', inputs.pr_number) || 'Agents Gate Followups' }} on: workflow_run: @@ -514,6 +515,48 @@ jobs: exit 1 fi + - name: Checkout authority release helpers + if: >- + always() && (failure() || cancelled()) && + needs.evaluate.outputs.dispatch_reason == 'due-authority-challenge' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + sparse-checkout: | + .github/scripts + scripts/runner_lib + scripts/state_fingerprint.py + sparse-checkout-cone-mode: false + + - name: Release prepared authority challenge after preflight failure + if: >- + always() && (failure() || cancelled()) && + needs.evaluate.outputs.dispatch_reason == 'due-authority-challenge' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PROVIDER: ${{ needs.evaluate.outputs.agent_type || 'codex' }} + PR_NUMBER: ${{ needs.evaluate.outputs.pr_number }} + HEAD_SHA: ${{ needs.evaluate.outputs.head_sha }} + AUTHORITY_CHALLENGE_FINGERPRINT: >- + ${{ github.event_name == 'workflow_dispatch' && + github.actor == 'github-actions[bot]' && + github.event.inputs.sweep_recheck == 'true' && + github.event.inputs.authority_challenge_fingerprint || '' }} + AUTHORITY_CHALLENGE_CLAIM: >- + ${{ github.event_name == 'workflow_dispatch' && + github.actor == 'github-actions[bot]' && + github.event.inputs.sweep_recheck == 'true' && + github.event.inputs.authority_challenge_claim || '' }} + AUTHORITY_CHALLENGE_SIGNING_KEY: >- + ${{ secrets.KEEPALIVE_AUTHORITY_SIGNING_KEY || '' }} + run: | + python -m scripts.runner_lib release-authority-challenge \ + --provider "$PROVIDER" \ + --pr-number "$PR_NUMBER" \ + --head-sha "$HEAD_SHA" \ + --storage auto + test-job: name: Test job creation needs: evaluate @@ -715,6 +758,62 @@ jobs: }; await markAgentRunning({ github, context, core, inputs }); + - name: Finalize authority challenge after mark-running + if: needs.evaluate.outputs.dispatch_reason == 'due-authority-challenge' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PROVIDER: ${{ needs.evaluate.outputs.agent_type || 'codex' }} + PR_NUMBER: ${{ needs.evaluate.outputs.pr_number }} + HEAD_SHA: ${{ needs.evaluate.outputs.head_sha }} + AUTHORITY_CHALLENGE_FINGERPRINT: >- + ${{ github.event_name == 'workflow_dispatch' && + github.actor == 'github-actions[bot]' && + github.event.inputs.sweep_recheck == 'true' && + github.event.inputs.authority_challenge_fingerprint || '' }} + AUTHORITY_CHALLENGE_CLAIM: >- + ${{ github.event_name == 'workflow_dispatch' && + github.actor == 'github-actions[bot]' && + github.event.inputs.sweep_recheck == 'true' && + github.event.inputs.authority_challenge_claim || '' }} + AUTHORITY_CHALLENGE_SIGNING_KEY: >- + ${{ secrets.KEEPALIVE_AUTHORITY_SIGNING_KEY || '' }} + run: | + python -m scripts.runner_lib finalize-authority-challenge \ + --provider "$PROVIDER" \ + --pr-number "$PR_NUMBER" \ + --head-sha "$HEAD_SHA" \ + --storage auto + + - name: Release prepared challenge after mark-running failure + if: >- + always() && (failure() || cancelled()) && + needs.evaluate.outputs.dispatch_reason == 'due-authority-challenge' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PROVIDER: ${{ needs.evaluate.outputs.agent_type || 'codex' }} + PR_NUMBER: ${{ needs.evaluate.outputs.pr_number }} + HEAD_SHA: ${{ needs.evaluate.outputs.head_sha }} + AUTHORITY_CHALLENGE_FINGERPRINT: >- + ${{ github.event_name == 'workflow_dispatch' && + github.actor == 'github-actions[bot]' && + github.event.inputs.sweep_recheck == 'true' && + github.event.inputs.authority_challenge_fingerprint || '' }} + AUTHORITY_CHALLENGE_CLAIM: >- + ${{ github.event_name == 'workflow_dispatch' && + github.actor == 'github-actions[bot]' && + github.event.inputs.sweep_recheck == 'true' && + github.event.inputs.authority_challenge_claim || '' }} + AUTHORITY_CHALLENGE_SIGNING_KEY: >- + ${{ secrets.KEEPALIVE_AUTHORITY_SIGNING_KEY || '' }} + run: | + python -m scripts.runner_lib release-authority-challenge \ + --provider "$PROVIDER" \ + --pr-number "$PR_NUMBER" \ + --head-sha "$HEAD_SHA" \ + --storage auto + # Route to appropriate agent based on agent:* label # Supports: agent:codex -> CLI Codex, agent:claude -> Claude API run-codex: diff --git a/.github/workflows/agents-auto-pilot.yml b/.github/workflows/agents-auto-pilot.yml index ff742e5b..514417d3 100644 --- a/.github/workflows/agents-auto-pilot.yml +++ b/.github/workflows/agents-auto-pilot.yml @@ -1113,7 +1113,7 @@ jobs: LANGCHAIN_TRACING_V2: ${{ secrets.LANGSMITH_API_KEY != '' && 'true' || 'false' }} LANGCHAIN_PROJECT: workflows-agents LANGCHAIN_PROVIDER: anthropic - LANGCHAIN_MODEL: claude-sonnet-4-5-20250929 + LANGCHAIN_MODEL: claude-sonnet-5-5 PYTHONPATH: ${{ github.workspace }} ISSUE_PR_CONTEXT_WORKFLOW: agents-auto-pilot run: | @@ -1497,7 +1497,7 @@ jobs: LANGCHAIN_TRACING_V2: ${{ secrets.LANGSMITH_API_KEY != '' && 'true' || 'false' }} LANGCHAIN_PROJECT: workflows-agents LANGCHAIN_PROVIDER: anthropic - LANGCHAIN_MODEL: claude-sonnet-4-5-20250929 + LANGCHAIN_MODEL: claude-sonnet-5-5 PYTHONPATH: ${{ github.workspace }} ISSUE_PR_CONTEXT_WORKFLOW: agents-auto-pilot run: | diff --git a/.github/workflows/agents-issue-optimizer.yml b/.github/workflows/agents-issue-optimizer.yml index 1fcb4c68..aa8e5e37 100644 --- a/.github/workflows/agents-issue-optimizer.yml +++ b/.github/workflows/agents-issue-optimizer.yml @@ -355,7 +355,7 @@ jobs: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} CLAUDE_API_STRANSKE: ${{ secrets.CLAUDE_API_STRANSKE }} LANGCHAIN_PROVIDER: anthropic - LANGCHAIN_MODEL: claude-sonnet-4-5-20250929 + LANGCHAIN_MODEL: claude-sonnet-5-5 PYTHONPATH: ${{ github.workspace }}/workflows-scripts run: | echo "Running analysis on issue #${ISSUE_NUMBER}" @@ -506,7 +506,7 @@ jobs: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} CLAUDE_API_STRANSKE: ${{ secrets.CLAUDE_API_STRANSKE }} LANGCHAIN_PROVIDER: anthropic - LANGCHAIN_MODEL: claude-sonnet-4-5-20250929 + LANGCHAIN_MODEL: claude-sonnet-5-5 PYTHONPATH: ${{ github.workspace }}/workflows-scripts run: | echo "Extracting suggestions from comments on issue #${ISSUE_NUMBER}" diff --git a/.github/workflows/agents-keepalive-loop-reporter.yml b/.github/workflows/agents-keepalive-loop-reporter.yml index 831cf8c4..6d0dfbb3 100644 --- a/.github/workflows/agents-keepalive-loop-reporter.yml +++ b/.github/workflows/agents-keepalive-loop-reporter.yml @@ -4,6 +4,12 @@ on: workflow_run: workflows: ["Agents Gate Followups"] types: [completed] + workflow_dispatch: + inputs: + pr_number: + description: 'Open PR whose current authority obligations should be replayed' + required: true + type: string permissions: contents: read @@ -11,34 +17,121 @@ permissions: issues: write actions: read -concurrency: - group: >- - keepalive-loop-reporter-${{ github.event.workflow_run.pull_requests[0].number || - github.run_id }} - cancel-in-progress: false - jobs: + resolve-target: + name: Resolve reporter target + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.workflow_run.conclusion != 'success' && + github.event.workflow_run.conclusion != 'skipped') + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + issues: read + pull-requests: read + outputs: + skip: ${{ steps.applicability.outputs.skip }} + lock_pr_number: ${{ steps.applicability.outputs.lock_pr_number }} + pr_number: ${{ steps.applicability.outputs.pr_number }} + ordinary_target: ${{ steps.applicability.outputs.ordinary_target }} + steps: + - name: Checkout reporter classifier + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + sparse-checkout: | + .github/scripts + sparse-checkout-cone-mode: false + fetch-depth: 1 + + - name: Classify unassociated dispatch + id: applicability + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + github-token: ${{ github.token }} + script: | + const { classifyReporterRun } = + require('./.github/scripts/keepalive_reporter_applicability.js'); + try { + if (context.eventName === 'workflow_dispatch') { + const resolved = Number(context.payload.inputs?.pr_number || 0); + if (!Number.isSafeInteger(resolved) || resolved <= 0 || + String(resolved) !== String(context.payload.inputs?.pr_number || '')) { + throw new Error('Authority replay requires a canonical positive PR number'); + } + core.setOutput('skip', 'false'); + core.setOutput('lock_pr_number', String(resolved)); + core.setOutput('pr_number', String(resolved)); + core.setOutput('ordinary_target', 'false'); + return; + } + const result = await classifyReporterRun({ + github, owner: context.repo.owner, repo: context.repo.repo, + run: context.payload.workflow_run, + }); + const associated = Number(context.payload.workflow_run.pull_requests?.[0]?.number || 0); + const resolved = Number(result.prNumber || associated || 0); + if (result.status !== 'skip' && + (!Number.isSafeInteger(resolved) || resolved <= 0)) { + throw new Error('Reporter target did not resolve to a positive PR number'); + } + core.setOutput('skip', result.status === 'skip' ? 'true' : 'false'); + core.setOutput('lock_pr_number', resolved > 0 ? String(resolved) : ''); + core.setOutput('pr_number', result.targetSource === 'ordinary-run-name' ? + String(result.prNumber) : ''); + core.setOutput('ordinary_target', + result.targetSource === 'ordinary-run-name' ? 'true' : 'false'); + } catch (error) { + core.setFailed(`Reporter applicability unavailable: ${error.message}`); + } + report: name: Report keepalive completion - if: vars.USE_CONSOLIDATED_WORKFLOWS == 'true' + needs: resolve-target + if: >- + needs.resolve-target.result == 'success' && + needs.resolve-target.outputs.skip != 'true' && + needs.resolve-target.outputs.lock_pr_number != '' && + (github.event_name == 'workflow_dispatch' || + (github.event.workflow_run.conclusion != 'success' && + github.event.workflow_run.conclusion != 'skipped')) + concurrency: + group: keepalive-loop-reporter-${{ needs.resolve-target.outputs.lock_pr_number }} + cancel-in-progress: false runs-on: ubuntu-latest steps: - name: Checkout keepalive scripts uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false sparse-checkout: | .github/scripts + .github/agents/registry.yml scripts/state_fingerprint.py sparse-checkout-cone-mode: false fetch-depth: 1 - name: Compute state fingerprint id: fingerprint + if: needs.resolve-target.outputs.skip != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number || '' }} + PR_NUMBER: >- + ${{ github.event.workflow_run.pull_requests[0].number || + needs.resolve-target.outputs.pr_number || '' }} run: | + if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then + { + echo "should_run=true" + echo "reason=authority-replay" + echo "current_hash=" + echo "prior_hash=" + } >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ -z "${PR_NUMBER:-}" ]; then { echo "should_run=true" @@ -59,6 +152,8 @@ jobs: run = event.get("workflow_run") or {} run_state = { "pr_number": int(os.environ["PR_NUMBER"]), + "run_id": int(run.get("id") or 0), + "run_attempt": int(run.get("run_attempt") or 1), "head_sha": run.get("head_sha") or "", "status": run.get("status") or "", "conclusion": run.get("conclusion") or "", @@ -83,7 +178,6 @@ jobs: if: >- steps.fingerprint.outputs.should_run == 'true' && github.event.workflow_run.conclusion != 'success' && - github.event.workflow_run.pull_requests[0].number && env.KEEPALIVE_APP_ID != '' && env.KEEPALIVE_APP_PRIVATE_KEY != '' uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 @@ -106,7 +200,6 @@ jobs: if: >- steps.fingerprint.outputs.should_run == 'true' && github.event.workflow_run.conclusion != 'success' && - github.event.workflow_run.pull_requests[0].number && steps.reporter_keepalive_app_token.outputs.token == '' && env.WORKFLOWS_APP_ID != '' && env.WORKFLOWS_APP_PRIVATE_KEY != '' @@ -127,8 +220,7 @@ jobs: - name: Require trusted keepalive reporter writer if: >- steps.fingerprint.outputs.should_run == 'true' && - github.event.workflow_run.conclusion != 'success' && - github.event.workflow_run.pull_requests[0].number + github.event.workflow_run.conclusion != 'success' env: KEEPALIVE_REPORTER_TOKEN: >- ${{ @@ -146,10 +238,12 @@ jobs: id: update-summary if: >- steps.fingerprint.outputs.should_run == 'true' && - github.event.workflow_run.conclusion != 'success' && - github.event.workflow_run.pull_requests[0].number + github.event.workflow_run.conclusion != 'success' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: + CLASSIFIED_PR_NUMBER: ${{ needs.resolve-target.outputs.pr_number || '' }} + LOCK_PR_NUMBER: ${{ needs.resolve-target.outputs.lock_pr_number || '' }} + CLASSIFIED_ORDINARY_TARGET: ${{ needs.resolve-target.outputs.ordinary_target || 'false' }} KEEPALIVE_SUMMARY_WRITER: >- ${{ steps.reporter_keepalive_app_token.outputs.token != '' && @@ -164,14 +258,38 @@ jobs: }} script: | const run = context.payload?.workflow_run || {}; - const prNumber = Number(run.pull_requests?.[0]?.number || 0); - if (!prNumber) { - core.info('No PR context found; skipping keepalive post summary.'); + const { replayReporterAuthority } = + require('./.github/scripts/keepalive_reporter_applicability.js'); + const replayPrNumber = Number( + process.env.LOCK_PR_NUMBER || + process.env.CLASSIFIED_PR_NUMBER || + run.pull_requests?.[0]?.number || + 0 + ); + let replay = { results: [] }; + try { + replay = await replayReporterAuthority({ + github, context, prNumber: replayPrNumber, + writerLogin: process.env.KEEPALIVE_SUMMARY_WRITER || '', + }); + core.info(`Authority replay outcomes: ${JSON.stringify(replay.results)}`); + } catch (error) { + if (context.eventName === 'workflow_dispatch') { + throw error; + } + core.warning( + `Authority replay failed: ${error.message}; continuing to reconciliation.`, + ); + } + if (context.eventName === 'workflow_dispatch') { return; } + let prNumber = Number( + run.pull_requests?.[0]?.number || process.env.CLASSIFIED_PR_NUMBER || 0 + ); const conclusion = String(run.conclusion || run.status || '').toLowerCase(); - if (!conclusion || conclusion === 'success') { + if (!conclusion || conclusion === 'success' || conclusion === 'skipped') { core.info( `Keepalive run conclusion=${conclusion || 'unknown'}; ` + 'no post summary needed.' @@ -181,8 +299,49 @@ jobs: 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, + run.head_sha || '' + ); + core.info(`Originating worker execution evidence: ${workerEvidence}`); + if (workerEvidence === 'unknown') { + core.setFailed('Originating worker execution evidence is unknown; retry this reporter'); + return; + } + let authorityRecovery; + try { + authorityRecovery = await recoverReporterAuthority({ + github, context, run, workerEvidence, + writerLogin: process.env.KEEPALIVE_SUMMARY_WRITER || '', prNumber, + }); + } catch (error) { + core.setFailed(`Authority reconciliation unavailable: ${error.message}`); + return; + } + prNumber = authorityRecovery.prNumber; + const authorityTarget = authorityRecovery.authorityTarget; + const ownerAttempt = authorityRecovery.ownerAttempt; + core.info( + `Attempt-bound authority reconciliation: ` + + `${authorityRecovery.reconciliation?.status || authorityRecovery.status}` + ); + if (['projected', 'superseded'].includes(authorityRecovery.status)) { + return; + } const { state } = await loadKeepaliveState({ github, context, prNumber, trace: '' }); + if (process.env.CLASSIFIED_ORDINARY_TARGET === 'true' && + state?.running_owner_attempt !== ownerAttempt) { + core.info( + 'Ordinary dispatch target no longer belongs to the originating run attempt; ' + + 'skipping stale cleanup.' + ); + return; + } if (!state || state.running !== true) { core.info('Keepalive state not marked as running; skipping post summary.'); return; @@ -190,9 +349,13 @@ jobs: const inputs = { pr_number: prNumber, - head_sha: run.head_sha || '', + head_sha: authorityTarget?.state?.head_sha || run.head_sha || '', action: 'run', reason: '', + agent_execution_started: workerEvidence === 'started' ? true : + workerEvidence === 'not-started' ? false : '', + authority_owner_attempt: + ownerAttempt, gate_conclusion: state.gate_conclusion || '', iteration: state.iteration || 0, max_iterations: state.max_iterations || 0, @@ -219,6 +382,9 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: >- + ${{ github.event.workflow_run.pull_requests[0].number || + needs.resolve-target.outputs.pr_number || '' }} run: | python scripts/state_fingerprint.py store \ --workflow "${GITHUB_WORKFLOW}" \ diff --git a/.github/workflows/agents-keepalive-sweep.yml b/.github/workflows/agents-keepalive-sweep.yml index b3830425..26cd0d2c 100644 --- a/.github/workflows/agents-keepalive-sweep.yml +++ b/.github/workflows/agents-keepalive-sweep.yml @@ -135,6 +135,7 @@ jobs: }); core.info(`Open PRs: ${prs.length}; non-draft keepalive agent PRs: ${eligible.length}`); let dispatched = 0; + let replayDispatched = 0; let challenged = 0; for (const pr of eligible) { if (dryRun) { @@ -142,6 +143,18 @@ jobs: continue; } try { + try { + await withRetry((client) => client.rest.actions.createWorkflowDispatch({ + owner, + repo, + workflow_id: 'agents-keepalive-loop-reporter.yml', + ref: context.payload.repository.default_branch, + inputs: { pr_number: String(pr.number) }, + })); + replayDispatched += 1; + } catch (error) { + core.warning(`PR #${pr.number}: authority replay dispatch failed: ${error.message}`); + } let dueChallenge = null; if ((pr.labels || []).some( (label) => (label.name || '').toLowerCase() === 'agent:needs-attention', @@ -243,7 +256,8 @@ jobs: await core.summary .addRaw( `Keepalive sweep (consolidated mode; vars.USE_CONSOLIDATED_WORKFLOWS=='true'): ` + - `${eligible.length} agent PR(s) eligible, ${dispatched} re-evaluated, ` + + `${eligible.length} agent PR(s) eligible, ${replayDispatched} authority replay(s) ` + + `requested, ${dispatched} re-evaluated, ` + `${challenged} due authority claim(s) challenged (dry_run=${dryRun}).`, ) .write(); @@ -319,6 +333,7 @@ jobs: `Open PRs: ${prs.length}; non-draft keepalive agent PRs: ${eligible.length}`, ); let dispatched = 0; + let replayDispatched = 0; let challenged = 0; for (const pr of eligible) { if (dryRun) { @@ -326,6 +341,18 @@ jobs: continue; } try { + try { + await withRetry((client) => client.rest.actions.createWorkflowDispatch({ + owner, + repo, + workflow_id: 'agents-keepalive-loop-reporter.yml', + ref: context.payload.repository.default_branch, + inputs: { pr_number: String(pr.number) }, + })); + replayDispatched += 1; + } catch (error) { + core.warning(`PR #${pr.number}: authority replay dispatch failed: ${error.message}`); + } let dueChallenge = null; if ((pr.labels || []).some( (label) => (label.name || '').toLowerCase() === 'agent:needs-attention', @@ -423,7 +450,8 @@ jobs: await core.summary .addRaw( `Keepalive sweep (non-consolidated mode; vars.USE_CONSOLIDATED_WORKFLOWS != 'true'): ` + - `${eligible.length} agent PR(s) eligible, ${dispatched} re-evaluated, ` + + `${eligible.length} agent PR(s) eligible, ${replayDispatched} authority replay(s) ` + + `requested, ${dispatched} re-evaluated, ` + `${challenged} due authority claim(s) challenged (dry_run=${dryRun}).`, ) .write(); diff --git a/.github/workflows/pr-00-gate-fork-status.yml b/.github/workflows/pr-00-gate-fork-status.yml new file mode 100644 index 00000000..4340d277 --- /dev/null +++ b/.github/workflows/pr-00-gate-fork-status.yml @@ -0,0 +1,48 @@ +name: Gate Fork Status Publisher + +'on': + # zizmor: ignore[dangerous-triggers] Trusted default-branch code only; no PR + # checkout, artifacts, or cache; exact run-attempt/head binding is enforced. + workflow_run: + workflows: + - Gate + types: + - requested + - in_progress + - completed + +permissions: + actions: read + contents: read + pull-requests: read + statuses: write + +concurrency: + group: gate-fork-status-${{ github.event.workflow_run.head_sha || github.run_id }} + cancel-in-progress: false + +jobs: + publish: + name: publish trusted fork Gate status + if: ${{ github.event.workflow_run.event == 'pull_request' }} + runs-on: ubuntu-latest + steps: + - name: Checkout trusted publisher + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + sparse-checkout: | + .github/scripts/error_classifier.js + .github/scripts/gate-fork-status-publication.js + .github/scripts/github-api-with-retry.js + .github/scripts/github-rate-limited-wrapper.js + .github/scripts/token_load_balancer.js + sparse-checkout-cone-mode: false + + - name: Publish exact-head Gate status + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const { publishGateForkStatus } = require('./.github/scripts/gate-fork-status-publication.js'); + await publishGateForkStatus({ github, context, core }); diff --git a/config/model_registry.json b/config/model_registry.json index b2b831ea..0b5c306a 100644 --- a/config/model_registry.json +++ b/config/model_registry.json @@ -15,8 +15,9 @@ ] }, "anthropic": { - "checked_at": "2026-09-22T00:00:00Z", + "checked_at": "2026-09-30T00:00:00Z", "model_ids": [ + "claude-sonnet-5-5", "claude-fable-5-1", "claude-opus-5", "claude-opus-5-5", @@ -95,6 +96,18 @@ "provider": "anthropic", "checked_at": "2026-09-22", "url": "https://platform.claude.com/docs/en/about-claude/models/overview" + }, + { + "source_id": "anthropic-catalog-discovery-2026-09-30", + "provider": "anthropic", + "checked_at": "2026-09-30", + "url": "https://api.anthropic.com/v1/models?limit=1000" + }, + { + "source_id": "anthropic-pricing-2026-09-30", + "provider": "anthropic", + "checked_at": "2026-09-30", + "url": "https://platform.claude.com/docs/en/about-claude/pricing" } ], "models": [ @@ -253,6 +266,23 @@ "as_of": "2026-07-10" } }, + { + "model_id": "claude-sonnet-5-5", + "provider": "anthropic", + "api": "chat", + "lifecycle": "current", + "positioning": "balanced", + "source_ids": [ + "anthropic-catalog-discovery-2026-09-30", + "anthropic-pricing-2026-09-30" + ], + "pricing": { + "currency": "USD", + "input_per_million_tokens": 2.0, + "output_per_million_tokens": 10.0, + "as_of": "2026-09-30" + } + }, { "model_id": "claude-sonnet-5", "provider": "anthropic", @@ -260,14 +290,15 @@ "lifecycle": "current", "positioning": "balanced", "source_ids": [ - "anthropic-current-models-2026-07-10" + "anthropic-current-models-2026-07-10", + "anthropic-pricing-2026-09-30" ], "pricing": { "currency": "USD", - "input_per_million_tokens": 3.0, - "output_per_million_tokens": 15.0, - "as_of": "2026-07-10", - "notes": "Standard price; temporary introductory pricing is not used for durable decisions." + "input_per_million_tokens": 2.0, + "output_per_million_tokens": 10.0, + "as_of": "2026-09-30", + "notes": "The $2/$10 launch price became the standard price; the scheduled 2026-09-01 increase to $3/$15 did not occur." } }, { @@ -371,14 +402,14 @@ { "profile": "verifier-balanced", "provider": "anthropic", - "model_id": "claude-sonnet-5", + "model_id": "claude-sonnet-5-5", "status": "provisional", - "decided_at": "2026-07-31", - "review_by": "2026-08-30", + "decided_at": "2026-09-30", + "review_by": "2026-10-30", "evidence_ids": [ - "catalog-review-2026-07-10" + "catalog-discovery-2026-09-30" ], - "rationale": "Provisional selection advanced to the current catalog generation; positioning 'balanced' matches the verifier-balanced profile. Cross-family relative to the superseded claude-opus incumbent, so it was human-initiated rather than auto-prepared. This is NOT a benchmark-proven promotion: no passing workload-benchmark evidence exists for this model or the incumbent, so status stays provisional until the paired pilot runs. Human-approved by merging this PR." + "rationale": "Provisional selection advanced to the current catalog generation: claude-sonnet-5-5 is the same-tier successor to claude-sonnet-5 (positioning 'balanced', same $2/$10 price). This is NOT a benchmark-proven promotion; status stays provisional until the paired pilot runs. claude-sonnet-5 remains catalogued as a pilot candidate." }, { "profile": "verifier-balanced", @@ -438,6 +469,21 @@ "superseded_at": "2026-07-31", "superseded_by": "openai/gpt-5", "supersede_reason": "Manual provisional refresh to the current catalog generation; not a benchmark-proven promotion." + }, + { + "profile": "verifier-balanced", + "provider": "anthropic", + "model_id": "claude-sonnet-5", + "status": "provisional", + "decided_at": "2026-07-31", + "review_by": "2026-08-30", + "evidence_ids": [ + "catalog-review-2026-07-10" + ], + "rationale": "Provisional selection advanced to the current catalog generation; positioning 'balanced' matches the verifier-balanced profile. Cross-family relative to the superseded claude-opus incumbent, so it was human-initiated rather than auto-prepared. This is NOT a benchmark-proven promotion: no passing workload-benchmark evidence exists for this model or the incumbent, so status stays provisional until the paired pilot runs. Human-approved by merging this PR.", + "superseded_at": "2026-09-30", + "superseded_by": "claude-sonnet-5-5", + "supersede_reason": "Manual provisional refresh to the current catalog generation (Sonnet 5.5 supersedes Sonnet 5 in the same balanced tier at the same price); not a benchmark-proven promotion." } ], "evidence": [ @@ -464,6 +510,17 @@ "anthropic-current-models-2026-09-22" ], "notes": "maint-77 catalog discovery (run 35796771035) observed claude-opus-5-5, claude-opus-5 and claude-fable-5-1 in the Anthropic /v1/models catalog. List pricing from the Anthropic models overview. Availability and pricing only; not workload quality evidence and cannot approve a selection." + }, + { + "evidence_id": "catalog-discovery-2026-09-30", + "kind": "provider-catalog-review", + "measured_at": "2026-09-30", + "status": "catalog-only", + "source_ids": [ + "anthropic-catalog-discovery-2026-09-30", + "anthropic-pricing-2026-09-30" + ], + "notes": "maint-77 catalog discovery (run 36712960738) observed claude-sonnet-5-5 in the Anthropic /v1/models catalog; list pricing from the Anthropic pricing page. Availability and pricing only; not workload quality evidence." } ] } diff --git a/docs/contracts/document-mirror-v1.md b/docs/contracts/document-mirror-v1.md index 1155403b..18ed39e3 100644 --- a/docs/contracts/document-mirror-v1.md +++ b/docs/contracts/document-mirror-v1.md @@ -33,7 +33,10 @@ validators, HTML resolvers, and the future `doc-mirror` CLI can share one schema HTTPS links are not valid upstream web resolvers. - `blob_path` is relative to `mirror_root` using POSIX `/` separators. Absolute paths, `..` traversal, Windows drive prefixes (`C:`), UNC prefixes (`//` or - `\\`), and backslashes are rejected. + `\\`), URI-style prefixes, empty path segments, trailing separators, and + backslashes are rejected. +- `mirror_root` names an absolute or repo-local filesystem directory. URI roots + such as `https://host/mirror` and `file:///tmp/mirror` are rejected. - Optional `supersedes_content_sha256` records checksum supersession without requiring consumers to import Pension-Data ingest helpers. @@ -42,10 +45,10 @@ validators, HTML resolvers, and the future `doc-mirror` CLI can share one schema | Field | Requirement | | --- | --- | | `schema_version` | Must be the literal `document-mirror/v1`. | -| `mirror_root` | Root directory of the mirrored blob store. | +| `mirror_root` | Absolute or repo-local filesystem root of the mirrored blob store; URI roots are rejected. | | `blobs` | Array of blob records (may be `[]` for an initialized empty catalog). | | `blobs[].content_sha256` | Lowercase 64-hex SHA-256 of the blob bytes. | -| `blobs[].blob_path` | Mirror-relative POSIX path (no `..`, absolute paths, drive/UNC prefixes, or backslashes). | +| `blobs[].blob_path` | Mirror-relative POSIX path (no URI prefix, `..`, absolute path, drive/UNC prefix, backslash, empty segment, or trailing separator). | | `blobs[].doc_type_id` | Fleet vocabulary token for the document type. | | `blobs[].source_refs` | Array of canonical refs and/or source-system objects; may be `[]` for local-only blobs. | diff --git a/docs/contracts/output-substrate-v1.md b/docs/contracts/output-substrate-v1.md index 792ea144..16e1745f 100644 --- a/docs/contracts/output-substrate-v1.md +++ b/docs/contracts/output-substrate-v1.md @@ -32,7 +32,7 @@ not change the wire shape of this document. | --- | --- | | `schema_version` | Must be the literal `output-substrate/v1`. | | `renderer_profile` | One of `investment_review`, `blackline_bundle`, `mosaic_book`. | -| `workspace_bundle_ref` | Run-dir-relative POSIX pointer to the view bundle JSON (`path` required; `sha256` and `artifact_id` recommended). Rejects absolute paths, `..` traversal, backslashes, drive-letter roots (`C:`), and UNC paths (`//server/share`). | +| `workspace_bundle_ref` | Run-dir-relative POSIX pointer to the view bundle JSON (`path` required; `sha256` and `artifact_id` recommended). Rejects absolute paths, URI-style prefixes, `..` traversal, backslashes, drive-letter roots (`C:`), empty path segments, and UNC paths (`//server/share`). | | `manifest_ref` | `artifact:manifest.json` or a run-dir-relative POSIX path to the [`artifact-manifest/v1`](schemas/artifact-manifest-v1.schema.json) manifest. Absolute paths, traversal, backslashes, drive roots, leading URI-style prefixes, and empty path segments are rejected. Colons in later path segments are allowed. Named artifacts live there, not inline. | | `manifest_csv_exports` | Array (possibly empty) of manifest-gated CSV export specs for Excel refresh. | @@ -42,8 +42,9 @@ Pension-Data-style `artifactBaseUrl` resolution. ## Manifest CSV exports -Each `manifest_csv_exports[]` entry names a generated CSV file, its encoding -(`utf-8` or `utf-16-le`), and a nonempty `columns[]` list. Every column +Each `manifest_csv_exports[]` entry names a run-dir-relative generated CSV file, +rejecting URI-style prefixes, traversal, and empty path segments. It also declares +the encoding (`utf-8` or `utf-16-le`) and a nonempty `columns[]` list. Every column requires `name`, `type` (`string`, `number`, `boolean`, or `date`), and `source_path` (JSONPath or consumer-defined pointer into the workspace bundle). Producers regenerate CSV files when the workspace bundle changes; Excel workbooks diff --git a/docs/contracts/run-contract-v1.md b/docs/contracts/run-contract-v1.md index 6c94179b..090d06de 100644 --- a/docs/contracts/run-contract-v1.md +++ b/docs/contracts/run-contract-v1.md @@ -89,6 +89,21 @@ and full model outputs must be represented by hashes, excerpts of bounded length, or artifact references** — never inlined. Output *data* lives in named artifacts referenced by the manifest, not in the envelope body. +## Pension-Data reference run + +`stranske/Pension-Data` is the fleet's first conforming **producer** for this +contract. The registry entry in `config/backplane_participants.json` names +`one-pdf-pilot` as the headless entry point and `run.json` / `manifest.json` as +the emitted artifacts. + +Implementation lives in `src/pension_data/ops/backplane_emitter.py` +(`build_backplane_reference_run`, roughly lines 112–235): given a completed +one-PDF pilot manifest, it writes `run-contract/v1` `run.json` and the companion +`manifest.json` under the pilot output directory. The `one-pdf-pilot` CLI +(`src/pension_data/ops/one_pdf_pilot_cli.py`) invokes that helper on every +successful pilot run so backplane outputs are produced alongside the existing +pilot artifacts. Conformance is covered by `tests/ops/test_backplane_emitter.py`. + ## Shared Fields Required fields: diff --git a/docs/contracts/schemas/document-mirror-v1.schema.json b/docs/contracts/schemas/document-mirror-v1.schema.json index cdd08942..db10e833 100644 --- a/docs/contracts/schemas/document-mirror-v1.schema.json +++ b/docs/contracts/schemas/document-mirror-v1.schema.json @@ -17,7 +17,8 @@ "mirror_root": { "type": "string", "minLength": 1, - "description": "Filesystem root of the mirrored blob store (absolute or repo-local path)." + "description": "Filesystem root of the mirrored blob store (absolute or repo-local path); URI schemes are not filesystem roots.", + "pattern": "^(?!(?:^|.*[/\\\\])\\.\\.(?:[/\\\\]|$))(?:(?:[A-Za-z]:\\\\(?:.+)?)|(?:[A-Za-z]:/(?!/)(?:.+)?)|(?!(?:[A-Za-z][A-Za-z0-9+.-]*:)).+)" }, "created_at": { "type": "string", @@ -48,8 +49,8 @@ "blob_path": { "type": "string", "minLength": 1, - "description": "Path relative to mirror_root. Absolute POSIX paths, Windows drive/UNC prefixes, backslashes, and '..' traversal are rejected by the validator.", - "pattern": "^(?!/)(?!//)(?![A-Za-z]:)(?!.*\\\\)(?!.*(^|/)\\.\\.(/|$)).+$" + "description": "Path relative to mirror_root. Absolute paths, URI schemes, Windows drive/UNC prefixes, backslashes, '..' traversal, empty path segments, and trailing separators are rejected by the validator.", + "pattern": "^(?!/)(?![A-Za-z]:)(?![A-Za-z][A-Za-z0-9+.-]*:)(?!.*\\\\)(?!.*(^|/)\\.\\.(/|$))(?!.*//)(?!.*\\/$).+$" }, "doc_type_id": { "type": "string", diff --git a/docs/contracts/schemas/output-substrate-v1.schema.json b/docs/contracts/schemas/output-substrate-v1.schema.json index 61216da5..e96e2d3f 100644 --- a/docs/contracts/schemas/output-substrate-v1.schema.json +++ b/docs/contracts/schemas/output-substrate-v1.schema.json @@ -41,8 +41,8 @@ "path": { "type": "string", "minLength": 1, - "description": "Run-dir-relative POSIX path. Absolute paths, '..' traversal, backslashes, drive-letter roots, and UNC paths are rejected by the validator.", - "pattern": "^(?!/)(?![A-Za-z]:)(?!.*\\\\)(?!.*(^|/)\\.\\.(/|$))(?!.*\\/$).+" + "description": "Run-dir-relative POSIX path. Absolute paths, URI schemes, '..' traversal, backslashes, drive-letter roots, empty path segments, and UNC paths are rejected by the validator.", + "pattern": "^(?!/)(?![A-Za-z]:)(?![A-Za-z][A-Za-z0-9+.-]*:)(?!.*\\\\)(?!.*(^|/)\\.\\.(/|$))(?!.*//)(?!.*\\/$).+" }, "sha256": { "type": "string", @@ -70,7 +70,7 @@ "filename": { "type": "string", "minLength": 1, - "pattern": "^(?!\\.$)(?!.*(?:^|/)\\.$)(?!/)(?![A-Za-z]:)(?!.*\\\\)(?!.*(^|/)\\.\\.(/|$)).*[^/]$" + "pattern": "^(?!\\.$)(?!.*(?:^|/)\\.$)(?!/)(?![A-Za-z]:)(?![A-Za-z][A-Za-z0-9+.-]*:)(?!.*\\\\)(?!.*(^|/)\\.\\.(/|$))(?!.*//).*[^/]$" }, "encoding": { "type": "string", diff --git a/scripts/check_deliberate_break.py b/scripts/check_deliberate_break.py index 38d7e1c8..0e8271e9 100644 --- a/scripts/check_deliberate_break.py +++ b/scripts/check_deliberate_break.py @@ -157,7 +157,7 @@ def _extract_fallback_test_name(named_line: str) -> str | None: if unquoted: name = unquoted.group(1) tail = named_line[unquoted.end() :] - if not tail or not (tail[0].isalnum() or tail[0] == "_"): + if not tail or not (tail[0].isascii() and (tail[0].isalnum() or tail[0] == "_")): return name return None @@ -857,7 +857,11 @@ def _assertion_diff_lines(diff_text: str) -> Iterator[str]: def _changed_assertions( - base: str, head: str, test_file: str, cwd: Path, pr_body: str | None = None # noqa: ARG001 + base: str, + head: str, + test_file: str, + cwd: Path, + pr_body: str | None = None, # noqa: ARG001 ) -> list[str]: """Keep the legacy body argument without letting PR text waive tamper checks.""" status = _git(["diff", "--name-status", f"{base}...{head}", "--", test_file], cwd) diff --git a/scripts/runner_lib/core.py b/scripts/runner_lib/core.py index 8c594505..44335007 100644 --- a/scripts/runner_lib/core.py +++ b/scripts/runner_lib/core.py @@ -1586,21 +1586,8 @@ def should_dispatch( ): return decision decision = DebounceDecision(True, "due-authority-challenge", key) - finalized = _authority_challenge_command("finalize", pr_number, head_sha, provider) - if not finalized or finalized.get("granted") is not True: - return DebounceDecision(False, "invalid-or-consumed-authority-challenge", key) - try: - reservation = storage.primary.read_record(pr_number, provider) - except Exception as exc: - _log_storage_failure("read", exc, phase="authority-reservation-readback") - return _unavailable_dispatch(key, prior) - if ( - not reservation - or reservation.get("status") != "pending" - or reservation.get("head_sha") != head_sha - or reservation.get("workflow_attempt_id") != _workflow_attempt_id() - ): - return DebounceDecision(False, "authority-reservation-changed", key) + # Finalize only after preflight succeeds (mark-running job); consuming here + # would strand the ledger when agent credentials are missing. return decision if prior and prior.get("head_sha") == head_sha: @@ -1695,6 +1682,90 @@ def should_dispatch( ) +def finalize_authority_challenge( + pr_number: int, + head_sha: str, + provider: str, + storage: RunnerDispatchStorage | None = None, +) -> DebounceDecision: + """Consume a prepared authority challenge after preflight passes.""" + provider = _validate_provider(provider) + storage = storage or _storage_from_name("auto") + key = _runner_key(pr_number, head_sha, provider) + if not isinstance(storage, FallbackRunnerStorage): + return DebounceDecision(False, "authority-storage-invalid", key) + finalized = _authority_challenge_command("finalize", pr_number, head_sha, provider) + if not finalized or finalized.get("granted") is not True: + return DebounceDecision(False, "invalid-or-consumed-authority-challenge", key) + try: + reservation = storage.primary.read_record(pr_number, provider) + except Exception as exc: + _log_storage_failure("read", exc, phase="authority-reservation-readback") + return _unavailable_dispatch(key) + if ( + not reservation + or reservation.get("status") != "pending" + or reservation.get("head_sha") != head_sha + or reservation.get("workflow_attempt_id") != _workflow_attempt_id() + ): + return DebounceDecision(False, "authority-reservation-changed", key) + return DebounceDecision(True, "due-authority-challenge", key) + + +def release_authority_challenge( + pr_number: int, + head_sha: str, + provider: str, + storage: RunnerDispatchStorage | None = None, +) -> bool: + """Terminalize this attempt's reservation, then refund its prepared challenge.""" + provider = _validate_provider(provider) + storage = storage or _storage_from_name("auto") + if not isinstance(storage, FallbackRunnerStorage): + return False + try: + reservation = storage.primary.read_record(pr_number, provider) + except Exception as exc: + _log_storage_failure("read", exc, phase="authority-release-reservation") + return False + if ( + not reservation + or reservation.get("head_sha") != head_sha + or reservation.get("workflow_attempt_id") != _workflow_attempt_id() + or reservation.get("key") != _runner_key(pr_number, head_sha, provider) + ): + return False + if reservation.get("status") == "pending": + completion = record_completion( + pr_number, + head_sha, + provider, + { + "provider": provider, + "success": False, + "summary": "Authority challenge released after runner preflight failure.", + "error": "runner-preflight-failed", + "truncated": False, + "final_message": "", + }, + storage=storage, + produced_work=False, + observed_head_sha=head_sha, + ) + if completion.get("status") != "error": + return False + elif not ( + reservation.get("status") == "error" + and isinstance(reservation.get("result"), dict) + and reservation["result"].get("error") == "runner-preflight-failed" + ): + # Only this exact attempt's cleanup completion may resume a failed + # release. An arbitrary terminal error must not refund authority. + return False + released = _authority_challenge_command("release", pr_number, head_sha, provider) + return bool(released and released.get("released") is True) + + def _authority_challenge_command( command: str, pr_number: int, head_sha: str, provider: str ) -> dict[str, Any] | None: @@ -2034,6 +2105,36 @@ def _cmd_parse(args: argparse.Namespace) -> int: return 0 +def _cmd_finalize_authority_challenge(args: argparse.Namespace) -> int: + decision = finalize_authority_challenge( + int(args.pr_number), + args.head_sha, + args.provider, + storage=_storage_from_name(args.storage), + ) + outputs = { + "finalized": "true" if decision.should_dispatch else "false", + "reason": decision.reason, + "key": decision.key, + } + _write_github_output(outputs) + print(json.dumps(outputs, sort_keys=True)) + return 0 if decision.should_dispatch else 1 + + +def _cmd_release_authority_challenge(args: argparse.Namespace) -> int: + released = release_authority_challenge( + int(args.pr_number), + args.head_sha, + args.provider, + storage=_storage_from_name(args.storage), + ) + outputs = {"released": "true" if released else "false"} + _write_github_output(outputs) + print(json.dumps(outputs, sort_keys=True)) + return 0 if released else 1 + + def _cmd_should_dispatch(args: argparse.Namespace) -> int: decision = should_dispatch( int(args.pr_number), @@ -2174,6 +2275,30 @@ def build_parser() -> argparse.ArgumentParser: help="validated JSON checklist snapshot captured when reserving the dispatch", ) + finalize_authority = subparsers.add_parser( + "finalize-authority-challenge", + help="consume a prepared authority challenge after preflight succeeds", + ) + finalize_authority.add_argument("--provider", choices=sorted(PROVIDERS), required=True) + finalize_authority.add_argument("--pr-number", required=True) + finalize_authority.add_argument("--head-sha", required=True) + finalize_authority.add_argument( + "--storage", choices=["auto", "pr-comment", "repo-variable"], default="auto" + ) + finalize_authority.set_defaults(func=_cmd_finalize_authority_challenge) + + release_authority = subparsers.add_parser( + "release-authority-challenge", + help="refund a prepared authority challenge when dispatch cannot run", + ) + release_authority.add_argument("--provider", choices=sorted(PROVIDERS), required=True) + release_authority.add_argument("--pr-number", required=True) + release_authority.add_argument("--head-sha", required=True) + release_authority.add_argument( + "--storage", choices=["auto", "pr-comment", "repo-variable"], default="auto" + ) + release_authority.set_defaults(func=_cmd_release_authority_challenge) + complete = subparsers.add_parser("record-completion", help="persist runner completion") complete.add_argument("--provider", choices=sorted(PROVIDERS), required=True) complete.add_argument("--pr-number", required=True) diff --git a/scripts/validate_run_contract.py b/scripts/validate_run_contract.py index d6fef06c..505a9eb4 100644 --- a/scripts/validate_run_contract.py +++ b/scripts/validate_run_contract.py @@ -173,6 +173,11 @@ def _check_document_page(document: Any, report: Report, prefix: str = "") -> Non locator = document.get("locator") if not isinstance(doc_ref, dict): return + if doc_ref.get("supersedes") == doc_ref.get("sha256"): + report.fail( + "document_ref.supersedes must name an earlier byte version", + f"{prefix}document_ref/supersedes", + ) if ( isinstance(locator, dict) and "page" in doc_ref diff --git a/tools/langchain_client.py b/tools/langchain_client.py index 04a90954..7feef3ab 100644 --- a/tools/langchain_client.py +++ b/tools/langchain_client.py @@ -178,13 +178,25 @@ def _anthropic_rejects_temperature(model: str) -> bool: custom ``temperature`` is set. Confirmed via the maint-78 verifier pilot (2026-07-24): ``claude-opus-4-8`` and ``claude-sonnet-5`` (while ``claude-opus-4-6`` still accepts ``temperature=0.1``). Matches Opus 4.8 explicitly plus the Claude 5 - family (``claude--5...``); minor-version ``-5`` suffixes like + family (``claude--5...``, which also covers ``claude-sonnet-5-5`` / ``claude-opus-5-5``); minor-version ``-5`` suffixes like ``claude-haiku-4-5`` are NOT matched. Interim, evidence-based guard; the durable capability-aware handling (e.g. retry-on-400) is tracked in stranske/Workflows#2819. """ name = model.lower().strip() - if name == "claude-opus-4-8": - return True + return name == "claude-opus-4-8" or _anthropic_is_claude5_family(name) + + +# langchain-anthropic resolves a missing ``max_tokens`` from its bundled model profiles and +# falls back to 4096 for any model it does not know. The Claude 5 family always thinks, and +# thinking tokens count against ``max_tokens``, so a model newer than the pinned package +# (``claude-sonnet-5-5``, ``claude-opus-5-5`` under langchain-anthropic 1.5.4) would have its +# answer truncated. Pin the ceiling explicitly for that family; 128000 is what the bundled +# ``claude-sonnet-5`` profile already resolves to, so the incumbent is unchanged. +_ANTHROPIC_THINKING_MAX_TOKENS = 128000 + + +def _anthropic_is_claude5_family(model: str) -> bool: + name = model.lower().strip() return any( name.startswith(f"claude-{family}-5") for family in ("opus", "sonnet", "haiku", "fable") ) @@ -201,6 +213,8 @@ def _build_anthropic_client( } if not _anthropic_rejects_temperature(model): kwargs["temperature"] = 0.1 + if _anthropic_is_claude5_family(model): + kwargs["max_tokens"] = _ANTHROPIC_THINKING_MAX_TOKENS return chat_anthropic(**kwargs)