diff --git a/README.md b/README.md index 8917d7d1c..02a72f41d 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,11 @@ flowchart LR AI capabilities require a configured agent runtime, an allowed model, and working authentication on the selected runner. Code-changing requests require repository write authority and run configured verification before commit/push. Release promotion follows the repository's merge policy. Installing Copilot does not grant permission to merge or deploy. +On shared runners, the runtime verifies the executing workflow's native PR or +branch concurrency before setup, so unrelated PRs can proceed independently. +Custom and legacy workflows retain the conservative predecessor queue. See +[workflow coordination and recovery](https://docs.page/vypdev/copilot/features#workflow-concurrency-and-sequential-execution). + ## Start with the web assistant Use **Node.js 24 or newer**, `pnpm`, and a working checkout of the repository you want to configure: diff --git a/build/cli/index.js b/build/cli/index.js index 0960966fa..bc1e7e6e4 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -36822,10 +36822,12 @@ async function mainRun(execution, projectBoardCommandPort, latestTagQueryPort, c (0, logger_1.logInfo)('GitHub Action: starting main run.'); (0, logger_1.logDebugInfo)(`Event: ${execution.eventName}, actor: ${execution.actor}, repo: ${repository.owner}/${repository.repo}, debug: ${execution.debug}`); if (process.env.GITHUB_ACTIONS === 'true' && !execution.singleAction.isPublishIssueCommentAction) { - // Every GitHub workflow invocation queues before setup or route work so - // executions of the same workflow file cannot overlap mutations. A - // failure notification must remain runnable when that queue gate fails. - await (0, main_run_lifecycle_1.waitForPreviousWorkflowRuns)(execution.tokens.token, repository); + // Verify native resource/lane coordination or retain the predecessor + // fence before setup. Failure notifications must remain runnable. + await (0, main_run_lifecycle_1.waitForPreviousWorkflowRuns)(execution.tokens.token, repository, { + isSingleAction: execution.isSingleAction, + pullRequestNumber: execution.inputs?.pull_request?.number, + }); } const setupExecution = (0, execution_setup_composition_root_1.createSetupExecutionUseCase)(latestTagQueryPort, { owner: repository.owner, @@ -37672,19 +37674,29 @@ class WorkflowQueueFailureError extends Error { } } exports.WorkflowQueueFailureError = WorkflowQueueFailureError; -function buildPreviousWorkflowRunsQuery(repository) { +function buildPreviousWorkflowRunsQuery(repository, event) { const workflowIdentifier = (0, workflow_context_1.resolveWorkflowIdentifier)(process.env.GITHUB_WORKFLOW_REF); const query = { owner: repository.owner, repository: repository.repo, - currentRunId: Number.parseInt(process.env.GITHUB_RUN_ID ?? '', 10), + currentRunId: Number(process.env.GITHUB_RUN_ID ?? Number.NaN), ...(workflowIdentifier ? { workflowIdentifier } : {}), + ...(event && !event.isSingleAction && process.env.GITHUB_WORKFLOW_SHA && process.env.GITHUB_JOB ? { + nativeCoordination: Object.freeze({ + workflowRef: process.env.GITHUB_WORKFLOW_REF ?? '', + workflowRevision: process.env.GITHUB_WORKFLOW_SHA, + jobId: process.env.GITHUB_JOB, + eventName: process.env.GITHUB_EVENT_NAME ?? '', + ref: process.env.GITHUB_REF, + pullRequestNumber: event.pullRequestNumber, + }), + } : {}), }; return query; } -async function waitForPreviousWorkflowRuns(token, repository) { - const query = buildPreviousWorkflowRunsQuery(repository); - if (process.env.GITHUB_ACTIONS === 'true' && !Number.isSafeInteger(query.currentRunId)) { +async function waitForPreviousWorkflowRuns(token, repository, event) { + const query = buildPreviousWorkflowRunsQuery(repository, event); + if (process.env.GITHUB_ACTIONS === 'true' && (!Number.isSafeInteger(query.currentRunId) || query.currentRunId <= 0)) { throw new Error('GitHub workflow identity is unavailable; refusing to bypass sequential execution.'); } if (process.env.GITHUB_ACTIONS === 'true' && !query.workflowIdentifier) { @@ -44668,6 +44680,60 @@ function mergeSetupOverrides(fileOverrides, flagOverrides) { } +/***/ }), + +/***/ 70998: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.expectedNativeWorkflowGroup = expectedNativeWorkflowGroup; +exports.hasNativeWorkflowCoordination = hasNativeWorkflowCoordination; +const PUSH_GROUP = 'copilot-push-${{ github.repository }}-${{ github.ref_name }}'; +const PR_GROUP = 'copilot-pr-${{ github.repository }}-${{ github.event.pull_request.number }}'; +/** Closed recognition of the installed contract, without evaluating expressions. */ +function expectedNativeWorkflowGroup(query) { + const native = query.nativeCoordination; + if (!native || !Number.isSafeInteger(query.currentRunId) || query.currentRunId <= 0 + || !/^[\w.-]+\.ya?ml$/u.test(query.workflowIdentifier ?? '') + || !native.workflowRef.startsWith(`${query.owner}/${query.repository}/.github/workflows/${query.workflowIdentifier}@`) + || native.workflowRef.endsWith('@') + || !/^[a-f\d]{40}$/iu.test(native.workflowRevision) + || !/^[a-zA-Z_][\w-]*$/u.test(native.jobId)) + return undefined; + if (native.eventName === 'push' && native.ref?.startsWith('refs/heads/') + && native.ref.length > 'refs/heads/'.length) + return PUSH_GROUP; + const pullRequestNumber = native.pullRequestNumber; + if (typeof pullRequestNumber !== 'number' || !Number.isSafeInteger(pullRequestNumber) || pullRequestNumber <= 0) + return undefined; + if (native.eventName === 'pull_request') + return `${PR_GROUP}-analysis`; + if (native.eventName === 'pull_request_review') + return `${PR_GROUP}-review-state`; + return undefined; +} +function hasNativeWorkflowCoordination(workflow, jobId, expectedGroup) { + const definition = record(workflow); + const jobs = record(definition?.jobs); + const job = record(jobs?.[jobId]); + const concurrency = record(job?.concurrency); + // Custom/reusable/workflow-level contracts retain conservative polling. + // A single direct job also avoids confusing a reusable callee's job ID + // with a different protected job in its caller's workflow context. + return definition?.concurrency === undefined && jobs !== undefined && Object.keys(jobs).length === 1 + && Array.isArray(job?.steps) + && job?.uses === undefined && concurrency?.group === expectedGroup + && concurrency?.['cancel-in-progress'] === true + && (concurrency.queue === undefined || concurrency.queue === 'single'); +} +function record(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value : undefined; +} + + /***/ }), /***/ 70628: @@ -73353,18 +73419,28 @@ const application_error_1 = __nccwpck_require__(75999); const SYSTEM_CLOCK = { nowMilliseconds: () => Date.now() }; const SYSTEM_RANDOM = { next: () => Math.random() }; class WaitForPreviousWorkflowRunsUseCase { - constructor(queryPort, delayPort, observerPort, policy = workflow_queue_policy_1.WORKFLOW_QUEUE_POLICY, clock = SYSTEM_CLOCK, random = SYSTEM_RANDOM) { + constructor(queryPort, delayPort, observerPort, policy = workflow_queue_policy_1.WORKFLOW_QUEUE_POLICY, clock = SYSTEM_CLOCK, random = SYSTEM_RANDOM, nativeCoordination) { this.queryPort = queryPort; this.delayPort = delayPort; this.observerPort = observerPort; this.policy = policy; this.clock = clock; this.random = random; + this.nativeCoordination = nativeCoordination; this.taskId = 'WaitForPreviousWorkflowRunsUseCase'; } async invoke(query) { const deadlineAtMilliseconds = this.clock.nowMilliseconds() + this.policy.maximumQueueWaitMilliseconds; let pollIndex = 0; + if (this.nativeCoordination && this.clock.nowMilliseconds() < deadlineAtMilliseconds) { + const coordinated = await this.nativeCoordination.isCoordinated(query, { deadlineAtMilliseconds }); + if (this.clock.nowMilliseconds() >= deadlineAtMilliseconds) + throw queueTimeoutError(); + if (coordinated) { + this.observerPort.nativeCoordinationVerified?.(); + return; + } + } while (true) { if (this.clock.nowMilliseconds() >= deadlineAtMilliseconds) { throw queueTimeoutError(); @@ -89585,6 +89661,55 @@ function isActivePreviousRun(run, query) { } +/***/ }), + +/***/ 74039: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.NativeWorkflowCoordinationRepository = void 0; +const js_yaml_1 = __nccwpck_require__(783); +const native_workflow_coordination_policy_1 = __nccwpck_require__(70998); +const workflow_runs_retry_1 = __nccwpck_require__(86434); +const MAX_WORKFLOW_BYTES = 256 * 1024; +/** Reads the workflow GitHub executed, never the possibly modified checkout. */ +class NativeWorkflowCoordinationRepository { + constructor(client, retry) { + this.client = client; + this.retry = retry; + } + async isCoordinated(query, context) { + const group = (0, native_workflow_coordination_policy_1.expectedNativeWorkflowGroup)(query); + const native = query.nativeCoordination; + if (!group || !native) + return false; + const response = await (0, workflow_runs_retry_1.withWorkflowRunsRetry)(() => this.client.rest.repos.getContent({ + owner: query.owner, + repo: query.repository, + path: `.github/workflows/${query.workflowIdentifier}`, + ref: native.workflowRevision, + }), { ...this.retry, policy: this.retry.policy ?? workflow_runs_retry_1.WORKFLOW_RUNS_RETRY_POLICY, + deadlineAtMilliseconds: context.deadlineAtMilliseconds }); + const file = response.data; + if (!file || Array.isArray(file) || file.type !== 'file' || file.encoding !== 'base64' + || typeof file.content !== 'string' || file.content.length > MAX_WORKFLOW_BYTES * 2) { + throw new Error('Executing workflow content is unavailable.'); + } + const encoded = file.content.replace(/\s/gu, ''); + const bytes = Buffer.from(encoded, 'base64'); + if (bytes.toString('base64') !== encoded) + throw new Error('Executing workflow encoding is invalid.'); + if (bytes.length > MAX_WORKFLOW_BYTES) + throw new Error('Executing workflow exceeds the inspection limit.'); + const workflow = (0, js_yaml_1.load)(bytes.toString('utf8'), { schema: js_yaml_1.JSON_SCHEMA }); + return (0, native_workflow_coordination_policy_1.hasNativeWorkflowCoordination)(workflow, native.jobId, group); + } +} +exports.NativeWorkflowCoordinationRepository = NativeWorkflowCoordinationRepository; + + /***/ }), /***/ 29509: @@ -97677,12 +97802,17 @@ const timer_workflow_polling_delay_adapter_1 = __nccwpck_require__(10339); const logger_workflow_polling_observer_adapter_1 = __nccwpck_require__(52883); const system_workflow_queue_clock_adapter_1 = __nccwpck_require__(49664); const system_workflow_polling_random_adapter_1 = __nccwpck_require__(32679); -const github_workflow_client_factory_1 = __nccwpck_require__(29839); +const native_workflow_coordination_repository_1 = __nccwpck_require__(74039); +const octokit_client_resolver_1 = __nccwpck_require__(54047); function createWaitForPreviousWorkflowRunsUseCase(token) { - const client = (0, github_workflow_client_factory_1.createWorkflowRunsClient)().getClient(token); + const client = (0, octokit_client_resolver_1.getOctokitClient)(token); const delayPort = new timer_workflow_polling_delay_adapter_1.TimerWorkflowPollingDelayAdapter(); const observerPort = new logger_workflow_polling_observer_adapter_1.LoggerWorkflowPollingObserverAdapter(); - return new wait_for_previous_workflow_runs_use_case_1.WaitForPreviousWorkflowRunsUseCase(new active_previous_workflow_runs_repository_1.ActivePreviousWorkflowRunsRepository(client, delayPort, undefined, new system_workflow_queue_clock_adapter_1.SystemWorkflowQueueClockAdapter(), new system_workflow_polling_random_adapter_1.SystemWorkflowPollingRandomAdapter(), observerPort), delayPort, observerPort); + const clock = new system_workflow_queue_clock_adapter_1.SystemWorkflowQueueClockAdapter(); + const random = new system_workflow_polling_random_adapter_1.SystemWorkflowPollingRandomAdapter(); + return new wait_for_previous_workflow_runs_use_case_1.WaitForPreviousWorkflowRunsUseCase(new active_previous_workflow_runs_repository_1.ActivePreviousWorkflowRunsRepository(client, delayPort, undefined, clock, random, observerPort), delayPort, observerPort, undefined, clock, random, new native_workflow_coordination_repository_1.NativeWorkflowCoordinationRepository(client, { + delayPort, clock, random, observer: observerPort, + })); } @@ -98983,6 +99113,9 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.LoggerWorkflowPollingObserverAdapter = void 0; const logger_1 = __nccwpck_require__(91151); class LoggerWorkflowPollingObserverAdapter { + nativeCoordinationVerified() { + (0, logger_1.logInfo)('Native workflow resource/lane coordination verified; continuing before setup.'); + } noActivePreviousRuns() { (0, logger_1.logDebugInfo)('✅ No previous runs active. Continuing...'); } diff --git a/build/github_action/index.js b/build/github_action/index.js index 2fd7f15bf..333bc5dcc 100644 --- a/build/github_action/index.js +++ b/build/github_action/index.js @@ -36807,10 +36807,12 @@ async function mainRun(execution, projectBoardCommandPort, latestTagQueryPort, c (0, logger_1.logInfo)('GitHub Action: starting main run.'); (0, logger_1.logDebugInfo)(`Event: ${execution.eventName}, actor: ${execution.actor}, repo: ${repository.owner}/${repository.repo}, debug: ${execution.debug}`); if (process.env.GITHUB_ACTIONS === 'true' && !execution.singleAction.isPublishIssueCommentAction) { - // Every GitHub workflow invocation queues before setup or route work so - // executions of the same workflow file cannot overlap mutations. A - // failure notification must remain runnable when that queue gate fails. - await (0, main_run_lifecycle_1.waitForPreviousWorkflowRuns)(execution.tokens.token, repository); + // Verify native resource/lane coordination or retain the predecessor + // fence before setup. Failure notifications must remain runnable. + await (0, main_run_lifecycle_1.waitForPreviousWorkflowRuns)(execution.tokens.token, repository, { + isSingleAction: execution.isSingleAction, + pullRequestNumber: execution.inputs?.pull_request?.number, + }); } const setupExecution = (0, execution_setup_composition_root_1.createSetupExecutionUseCase)(latestTagQueryPort, { owner: repository.owner, @@ -38253,19 +38255,29 @@ class WorkflowQueueFailureError extends Error { } } exports.WorkflowQueueFailureError = WorkflowQueueFailureError; -function buildPreviousWorkflowRunsQuery(repository) { +function buildPreviousWorkflowRunsQuery(repository, event) { const workflowIdentifier = (0, workflow_context_1.resolveWorkflowIdentifier)(process.env.GITHUB_WORKFLOW_REF); const query = { owner: repository.owner, repository: repository.repo, - currentRunId: Number.parseInt(process.env.GITHUB_RUN_ID ?? '', 10), + currentRunId: Number(process.env.GITHUB_RUN_ID ?? Number.NaN), ...(workflowIdentifier ? { workflowIdentifier } : {}), + ...(event && !event.isSingleAction && process.env.GITHUB_WORKFLOW_SHA && process.env.GITHUB_JOB ? { + nativeCoordination: Object.freeze({ + workflowRef: process.env.GITHUB_WORKFLOW_REF ?? '', + workflowRevision: process.env.GITHUB_WORKFLOW_SHA, + jobId: process.env.GITHUB_JOB, + eventName: process.env.GITHUB_EVENT_NAME ?? '', + ref: process.env.GITHUB_REF, + pullRequestNumber: event.pullRequestNumber, + }), + } : {}), }; return query; } -async function waitForPreviousWorkflowRuns(token, repository) { - const query = buildPreviousWorkflowRunsQuery(repository); - if (process.env.GITHUB_ACTIONS === 'true' && !Number.isSafeInteger(query.currentRunId)) { +async function waitForPreviousWorkflowRuns(token, repository, event) { + const query = buildPreviousWorkflowRunsQuery(repository, event); + if (process.env.GITHUB_ACTIONS === 'true' && (!Number.isSafeInteger(query.currentRunId) || query.currentRunId <= 0)) { throw new Error('GitHub workflow identity is unavailable; refusing to bypass sequential execution.'); } if (process.env.GITHUB_ACTIONS === 'true' && !query.workflowIdentifier) { @@ -46209,6 +46221,60 @@ function boundedMergeQueueDiagnostic(value) { } +/***/ }), + +/***/ 70998: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.expectedNativeWorkflowGroup = expectedNativeWorkflowGroup; +exports.hasNativeWorkflowCoordination = hasNativeWorkflowCoordination; +const PUSH_GROUP = 'copilot-push-${{ github.repository }}-${{ github.ref_name }}'; +const PR_GROUP = 'copilot-pr-${{ github.repository }}-${{ github.event.pull_request.number }}'; +/** Closed recognition of the installed contract, without evaluating expressions. */ +function expectedNativeWorkflowGroup(query) { + const native = query.nativeCoordination; + if (!native || !Number.isSafeInteger(query.currentRunId) || query.currentRunId <= 0 + || !/^[\w.-]+\.ya?ml$/u.test(query.workflowIdentifier ?? '') + || !native.workflowRef.startsWith(`${query.owner}/${query.repository}/.github/workflows/${query.workflowIdentifier}@`) + || native.workflowRef.endsWith('@') + || !/^[a-f\d]{40}$/iu.test(native.workflowRevision) + || !/^[a-zA-Z_][\w-]*$/u.test(native.jobId)) + return undefined; + if (native.eventName === 'push' && native.ref?.startsWith('refs/heads/') + && native.ref.length > 'refs/heads/'.length) + return PUSH_GROUP; + const pullRequestNumber = native.pullRequestNumber; + if (typeof pullRequestNumber !== 'number' || !Number.isSafeInteger(pullRequestNumber) || pullRequestNumber <= 0) + return undefined; + if (native.eventName === 'pull_request') + return `${PR_GROUP}-analysis`; + if (native.eventName === 'pull_request_review') + return `${PR_GROUP}-review-state`; + return undefined; +} +function hasNativeWorkflowCoordination(workflow, jobId, expectedGroup) { + const definition = record(workflow); + const jobs = record(definition?.jobs); + const job = record(jobs?.[jobId]); + const concurrency = record(job?.concurrency); + // Custom/reusable/workflow-level contracts retain conservative polling. + // A single direct job also avoids confusing a reusable callee's job ID + // with a different protected job in its caller's workflow context. + return definition?.concurrency === undefined && jobs !== undefined && Object.keys(jobs).length === 1 + && Array.isArray(job?.steps) + && job?.uses === undefined && concurrency?.group === expectedGroup + && concurrency?.['cancel-in-progress'] === true + && (concurrency.queue === undefined || concurrency.queue === 'single'); +} +function record(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value : undefined; +} + + /***/ }), /***/ 70628: @@ -69055,18 +69121,28 @@ const application_error_1 = __nccwpck_require__(75999); const SYSTEM_CLOCK = { nowMilliseconds: () => Date.now() }; const SYSTEM_RANDOM = { next: () => Math.random() }; class WaitForPreviousWorkflowRunsUseCase { - constructor(queryPort, delayPort, observerPort, policy = workflow_queue_policy_1.WORKFLOW_QUEUE_POLICY, clock = SYSTEM_CLOCK, random = SYSTEM_RANDOM) { + constructor(queryPort, delayPort, observerPort, policy = workflow_queue_policy_1.WORKFLOW_QUEUE_POLICY, clock = SYSTEM_CLOCK, random = SYSTEM_RANDOM, nativeCoordination) { this.queryPort = queryPort; this.delayPort = delayPort; this.observerPort = observerPort; this.policy = policy; this.clock = clock; this.random = random; + this.nativeCoordination = nativeCoordination; this.taskId = 'WaitForPreviousWorkflowRunsUseCase'; } async invoke(query) { const deadlineAtMilliseconds = this.clock.nowMilliseconds() + this.policy.maximumQueueWaitMilliseconds; let pollIndex = 0; + if (this.nativeCoordination && this.clock.nowMilliseconds() < deadlineAtMilliseconds) { + const coordinated = await this.nativeCoordination.isCoordinated(query, { deadlineAtMilliseconds }); + if (this.clock.nowMilliseconds() >= deadlineAtMilliseconds) + throw queueTimeoutError(); + if (coordinated) { + this.observerPort.nativeCoordinationVerified?.(); + return; + } + } while (true) { if (this.clock.nowMilliseconds() >= deadlineAtMilliseconds) { throw queueTimeoutError(); @@ -80097,6 +80173,55 @@ function isActivePreviousRun(run, query) { } +/***/ }), + +/***/ 74039: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.NativeWorkflowCoordinationRepository = void 0; +const js_yaml_1 = __nccwpck_require__(783); +const native_workflow_coordination_policy_1 = __nccwpck_require__(70998); +const workflow_runs_retry_1 = __nccwpck_require__(86434); +const MAX_WORKFLOW_BYTES = 256 * 1024; +/** Reads the workflow GitHub executed, never the possibly modified checkout. */ +class NativeWorkflowCoordinationRepository { + constructor(client, retry) { + this.client = client; + this.retry = retry; + } + async isCoordinated(query, context) { + const group = (0, native_workflow_coordination_policy_1.expectedNativeWorkflowGroup)(query); + const native = query.nativeCoordination; + if (!group || !native) + return false; + const response = await (0, workflow_runs_retry_1.withWorkflowRunsRetry)(() => this.client.rest.repos.getContent({ + owner: query.owner, + repo: query.repository, + path: `.github/workflows/${query.workflowIdentifier}`, + ref: native.workflowRevision, + }), { ...this.retry, policy: this.retry.policy ?? workflow_runs_retry_1.WORKFLOW_RUNS_RETRY_POLICY, + deadlineAtMilliseconds: context.deadlineAtMilliseconds }); + const file = response.data; + if (!file || Array.isArray(file) || file.type !== 'file' || file.encoding !== 'base64' + || typeof file.content !== 'string' || file.content.length > MAX_WORKFLOW_BYTES * 2) { + throw new Error('Executing workflow content is unavailable.'); + } + const encoded = file.content.replace(/\s/gu, ''); + const bytes = Buffer.from(encoded, 'base64'); + if (bytes.toString('base64') !== encoded) + throw new Error('Executing workflow encoding is invalid.'); + if (bytes.length > MAX_WORKFLOW_BYTES) + throw new Error('Executing workflow exceeds the inspection limit.'); + const workflow = (0, js_yaml_1.load)(bytes.toString('utf8'), { schema: js_yaml_1.JSON_SCHEMA }); + return (0, native_workflow_coordination_policy_1.hasNativeWorkflowCoordination)(workflow, native.jobId, group); + } +} +exports.NativeWorkflowCoordinationRepository = NativeWorkflowCoordinationRepository; + + /***/ }), /***/ 29509: @@ -88044,12 +88169,17 @@ const timer_workflow_polling_delay_adapter_1 = __nccwpck_require__(10339); const logger_workflow_polling_observer_adapter_1 = __nccwpck_require__(52883); const system_workflow_queue_clock_adapter_1 = __nccwpck_require__(49664); const system_workflow_polling_random_adapter_1 = __nccwpck_require__(32679); -const github_workflow_client_factory_1 = __nccwpck_require__(29839); +const native_workflow_coordination_repository_1 = __nccwpck_require__(74039); +const octokit_client_resolver_1 = __nccwpck_require__(54047); function createWaitForPreviousWorkflowRunsUseCase(token) { - const client = (0, github_workflow_client_factory_1.createWorkflowRunsClient)().getClient(token); + const client = (0, octokit_client_resolver_1.getOctokitClient)(token); const delayPort = new timer_workflow_polling_delay_adapter_1.TimerWorkflowPollingDelayAdapter(); const observerPort = new logger_workflow_polling_observer_adapter_1.LoggerWorkflowPollingObserverAdapter(); - return new wait_for_previous_workflow_runs_use_case_1.WaitForPreviousWorkflowRunsUseCase(new active_previous_workflow_runs_repository_1.ActivePreviousWorkflowRunsRepository(client, delayPort, undefined, new system_workflow_queue_clock_adapter_1.SystemWorkflowQueueClockAdapter(), new system_workflow_polling_random_adapter_1.SystemWorkflowPollingRandomAdapter(), observerPort), delayPort, observerPort); + const clock = new system_workflow_queue_clock_adapter_1.SystemWorkflowQueueClockAdapter(); + const random = new system_workflow_polling_random_adapter_1.SystemWorkflowPollingRandomAdapter(); + return new wait_for_previous_workflow_runs_use_case_1.WaitForPreviousWorkflowRunsUseCase(new active_previous_workflow_runs_repository_1.ActivePreviousWorkflowRunsRepository(client, delayPort, undefined, clock, random, observerPort), delayPort, observerPort, undefined, clock, random, new native_workflow_coordination_repository_1.NativeWorkflowCoordinationRepository(client, { + delayPort, clock, random, observer: observerPort, + })); } @@ -89058,6 +89188,9 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.LoggerWorkflowPollingObserverAdapter = void 0; const logger_1 = __nccwpck_require__(91151); class LoggerWorkflowPollingObserverAdapter { + nativeCoordinationVerified() { + (0, logger_1.logInfo)('Native workflow resource/lane coordination verified; continuing before setup.'); + } noActivePreviousRuns() { (0, logger_1.logDebugInfo)('✅ No previous runs active. Continuing...'); } diff --git a/docs/features.mdx b/docs/features.mdx index 8339fd373..8acd72fbc 100644 --- a/docs/features.mdx +++ b/docs/features.mdx @@ -166,7 +166,7 @@ decorative media or expose configuration for it. ## Workflow concurrency and sequential execution - **Coordination policies:** issue and comment mutation workflows preserve admitted events in a workflow-local queue. Deployment mutations use a shared issue-scoped native queue. Commit and Pull Request each have a separate repository/branch latest-revision lane, so paired events cannot cancel one another. + **Coordination policies:** issue and comment mutation workflows preserve admitted events in a workflow-local queue. Deployment mutations use a shared issue-scoped native queue. Commit uses a branch lane; PR analysis and review-state use separate lanes per PR number. The runtime verifies those native lanes before setup, so unrelated PRs do not wait for each other while occupying shared runners. GitHub's native [concurrency](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#concurrency) can cancel in-progress runs when a newer one starts. Its default `queue: single` retains only one pending run; `queue: max` allows up to 100 pending jobs or workflow runs when `cancel-in-progress` is `false` or omitted. The replaceable event jobs therefore use event-specific keys: Commit uses `copilot-push-${{ github.repository }}-${{ github.ref_name }}`; Pull Request code/lifecycle analysis uses the repository and PR number with an `analysis` suffix; and the dedicated review-state workflow uses the same PR-number key with a `review-state` suffix. Different PRs sharing a head branch cannot cancel one another. Each PR lane cancels only its own obsolete revision. Commit still synchronizes issue progress, then Bugbot performs a read-only exact-head lookup because a `push` payload does not contain PR identity. A validated open same-repository PR makes the push stop before review-context loading or agent invocation; the PR `synchronize` event exclusively owns review for that head. This prevents duplicate reviews without letting a push or review-state event cancel useful PR analysis. Branches without an open PR retain push-time Bugbot. Metadata-only `pull_request: edited` events are not subscribed because Copilot changes the PR body itself; excluding them prevents self-generated run cascades and preserves human metadata edits. Application-level head guards prevent an older run from updating a newer PR, and the next code/lifecycle run repairs any durable half-transition discovered after cancellation. Fixed workflow/job names expose analysis and review-state checks without leaking an unevaluated expression when GitHub skips bot-authored events. A separate merge-group workflow avoids a skipped duplicate on normal PRs while deliberately preserving the exact `Copilot - Pull Request` required-check context for branch-protection compatibility. @@ -184,10 +184,19 @@ is no alternate queue-only Action mode or direct/local deployment mutation path. ### How it works 1. For a GitHub Action run with a PAT, the action first compares the event actor with the authenticated PAT user. A normal run from the same account completes successfully before project composition, agent provisioning, setup, or queue polling. A valid explicit single action continues through the normal lifecycle. -2. An admitted run resolves the current workflow file from `GITHUB_WORKFLOW_REF` and queries the workflow-scoped endpoint concurrently for `in_progress`, `queued`, `requested`, `waiting`, and `pending` runs with `per_page: 100`. It then keeps only runs with a **lower run ID** (i.e. started earlier). Completed history and unrelated workflows are never included. -3. Provider failures fail closed. Transient 408/5xx/network errors use bounded exponential retry; HTTP 429 and rate-limited 403 responses honor `Retry-After` or `x-ratelimit-reset`, then use a slower bounded fallback. Diagnostics contain only the retry reason, attempt, delay, and safe reset timestamp metadata. -4. If any such run exists, the action polls immediately and then uses adaptive 5s, 10s, 20s, 40s, and 60s maximum delays with bounded ±20% jitter. The absolute queue wait is limited to 90 minutes. -5. When no earlier active run in the mutation queue remains, the action continues. A provider failure or queue deadline never becomes an empty result, so setup and mutation work cannot proceed with an unknown queue state. +2. Before setup, an admitted normal branch push, PR or review-state run can prove native coordination. Copilot reads the workflow from the current repository at `GITHUB_WORKFLOW_SHA`, uses `GITHUB_JOB` to select the executing job, and requires its exact group above with literal `cancel-in-progress: true`. It reads GitHub's pinned source using existing repository contents-read access, never the checkout or a moving branch. Proven jobs log `Native workflow resource/lane coordination verified; continuing before setup.` and use GitHub's existing resource/lane fence without polling other runs. The checks and findings still run normally. +3. Without proof, the existing workflow queue applies: this includes legacy/custom groups, reusable or multi-job workflows, missing metadata, single actions and unrelated event types. Copilot resolves the workflow file from `GITHUB_WORKFLOW_REF` and queries the workflow-scoped endpoint concurrently for `in_progress`, `queued`, `requested`, `waiting`, and `pending` runs with `per_page: 100`. It keeps only runs with a **lower run ID**. Completed history and unrelated workflows are never included. A matching filename alone does not establish native coordination. +4. Provider failures fail closed for both native inspection and predecessor queries. Transient 408/5xx/network errors use bounded exponential retry; HTTP 429 and rate-limited 403 responses honor `Retry-After` or `x-ratelimit-reset`, then use a slower bounded fallback. Diagnostics contain only the retry reason, attempt, delay, and safe reset timestamp metadata. +5. If any predecessor exists, the action polls immediately and then uses adaptive 5s, 10s, 20s, 40s, and 60s maximum delays with bounded ±20% jitter. Native inspection and polling share the absolute 90-minute queue budget. +6. When no earlier active run in the mutation queue remains, the action continues. A provider failure, malformed workflow source or queue deadline never grants permission to execute, so setup and mutation work cannot proceed with an unknown coordination state. + +After upgrading the runtime, installations already using the current exact +groups need no workflow change or new Variable. If an older runtime is already +waiting while occupying a shared runner, inspect the waiting run and its +predecessors in Actions. Preserve necessary predecessor callbacks and rerun the +affected workflow after the fix is available; a running older process cannot +adopt the new policy. Legacy/custom workflows retain conservative serialization. +Do not remove their queue or weaken checks to recover runner capacity. The queue deliberately traverses every page in each active-status subset because exact counting must detect a matching run on a later page. Querying only active diff --git a/docs/pull-requests/workflow-setup.mdx b/docs/pull-requests/workflow-setup.mdx index 0ef51e1ee..1adde09e5 100644 --- a/docs/pull-requests/workflow-setup.mdx +++ b/docs/pull-requests/workflow-setup.mdx @@ -129,6 +129,17 @@ reviewed maintenance PR. Update analysis and review-state together and retain project-specific setup/release customizations. No new Variable, Secret or permission is required. Current-head checks and review remain the merge gate. +The runtime verifies these native groups before setup by reading the executing +workflow at GitHub's `GITHUB_WORKFLOW_SHA` and selecting `GITHUB_JOB`. Existing +repository contents-read access is required. Verified PR lanes do not poll +queued runs for other PRs, which prevents a later job from holding a shared +runner while its predecessor needs that same pool. Same-PR/lane replacement +and all application freshness, findings and permission checks remain in force. +There is no queue-bypass setting. Missing metadata or custom concurrency uses +the conservative workflow queue; a workflow-read failure stops before setup. +See [coordination and recovery](/features#workflow-concurrency-and-sequential-execution) +for upgrading and rerunning affected older jobs. + - **`token`** is required (use a fine-grained PAT with repo and project permissions; see [Authentication](/authentication)). - **`project-ids`** is optional but needed if you want PRs linked to GitHub Project boards and moved to columns (e.g. "In Progress"). - The conditional fetch downloads only the two commit objects needed by the incremental reviewer. Keep it when `synchronize` is enabled; a full-history checkout is not required. diff --git a/specs/CATALOG.md b/specs/CATALOG.md index a0d046df3..c445d59b6 100644 --- a/specs/CATALOG.md +++ b/specs/CATALOG.md @@ -14,7 +14,7 @@ debt or convert unknown historic intent into a design decision. | `release-orchestration` | Implemented | Release and hotfix promotion, publication, bounded merge readiness recovery, and receipt-verified reconciliation without republication | [Configurable production-first release orchestration](./configurable-release-orchestration.md) + 2 companion | 59 paths · 2026-10-16 | | `merge-queue-readiness` | Implemented | Fail-closed validation of required checks and merge-group workflow support; optional manual-only RepoWise analysis with independent CI and verified required-check migration; cached RepoWise exports and bounded full-history secret processing with truthful completeness and resource evidence | [Merge queue readiness and effective target rules](./merge-queue-readiness.md) | 29 paths · 2026-10-21 | | `bugbot-review-state-reconciliation` | Implemented | Reconcile review snapshots, findings, threads, comments, and check conclusions | [Bugbot review-state reconciliation](./bugbot-review-state-reconciliation.md) | 74 paths · 2026-10-24 | -| `execution-lifecycle` | Implemented | Shared GitHub Action lifecycle from event admission through durable user-facing results | [Execution admission, queueing, routing, and result publication](./execution-admission-queue-and-publication.md) + 3 companion | 105 paths · 2026-10-21 | +| `execution-lifecycle` | Implemented | Shared GitHub Action lifecycle with pinned native resource/lane coordination proof, conservative predecessor fencing, routing and durable user-facing results | [Execution admission, queueing, routing, and result publication](./execution-admission-queue-and-publication.md) + 3 companion | 119 paths · 2026-10-25 | | `architecture-quality-hardening` | Implemented | Close verified concurrency, error-contract, context-coupling, fan-out, setup/doctor, and provider-policy risks in dependency order | [Architecture quality and scalability hardening](./architecture-quality-and-scalability-hardening.md) + 1 companion | 72 paths · 2026-10-13 | | `setup-and-doctor` | Implemented | Plan, validate, provision, and audit a repository installation without exposing credentials | [Setup, configuration, credentials, and doctor](./setup-configuration-credentials-and-doctor.md) + 6 companion | 332 paths · 2026-10-24 | | `local-web-setup-assistant` | Proposed | Offer a packaged, loopback-only Svelte setup interface over the existing engine with four-language guidance, English CLI help, basic/custom first-run paths, editable question and plan review, source-labelled GitHub facts, assisted CI and Project evidence, role-separated PATs, and truthful itemized outcomes; inspect existing configuration and apply independent, scope-aware runtime adjustments with permission, drift and read-back verification | [Local web setup assistant](./local-web-setup-assistant.md) + 2 companion | 247 paths · 2026-10-21 | @@ -81,13 +81,13 @@ debt or convert unknown historic intent into a design decision. ### `execution-lifecycle` — Execution admission, queueing, routing, and result publication - Owner: Copilot maintainers -- Last verified: 2026-10-21 +- Last verified: 2026-10-25 - Specifications: [`specs/execution-admission-queue-and-publication.md`](./execution-admission-queue-and-publication.md) · [`specs/execution-error-and-context-hardening.md`](./execution-error-and-context-hardening.md) · [`specs/execution-boundary-closure-audit.md`](./execution-boundary-closure-audit.md) · [`specs/push-and-single-action-context-hardening.md`](./push-and-single-action-context-hardening.md) - Workflows: [`.github/workflows/copilot_issue.yml`](../.github/workflows/copilot_issue.yml) · [`.github/workflows/copilot_issue_comment.yml`](../.github/workflows/copilot_issue_comment.yml) · [`.github/workflows/copilot_pull_request.yml`](../.github/workflows/copilot_pull_request.yml) · [`.github/workflows/copilot_pull_request_comment.yml`](../.github/workflows/copilot_pull_request_comment.yml) · [`.github/workflows/copilot_commit.yml`](../.github/workflows/copilot_commit.yml) - Entrypoints: [`src/actions/github_action.ts`](../src/actions/github_action.ts) · [`src/actions/common_action.ts`](../src/actions/common_action.ts) -- Core code: [`scripts/coverage-budgets.json`](../scripts/coverage-budgets.json) · [`scripts/validate-coverage-budgets.cjs`](../scripts/validate-coverage-budgets.cjs) · [`src/actions/main_run_lifecycle.ts`](../src/actions/main_run_lifecycle.ts) · [`src/actions/main_run_route.ts`](../src/actions/main_run_route.ts) · [`src/actions/github_action_completion.ts`](../src/actions/github_action_completion.ts) · [`src/api.ts`](../src/api.ts) · [`src/data/model/application_error.ts`](../src/data/model/application_error.ts) · [`src/application/errors/application_error.ts`](../src/application/errors/application_error.ts) · [`src/application/errors/application_error_context.ts`](../src/application/errors/application_error_context.ts) · [`src/application/policies/application_error_message_catalog.ts`](../src/application/policies/application_error_message_catalog.ts) · [`src/application/policies/application_error_presentation_policy.ts`](../src/application/policies/application_error_presentation_policy.ts) · [`src/application/policies/bugbot_result_finding_state_projection_policy.ts`](../src/application/policies/bugbot_result_finding_state_projection_policy.ts) · [`src/application/policies/bugbot_telemetry_projection_policy.ts`](../src/application/policies/bugbot_telemetry_projection_policy.ts) · [`src/application/policies/action_summary_policy.ts`](../src/application/policies/action_summary_policy.ts) · [`src/application/policies/copilot_evidence_policy.ts`](../src/application/policies/copilot_evidence_policy.ts) · [`src/application/policies/lifecycle_state_policy.ts`](../src/application/policies/lifecycle_state_policy.ts) · [`src/application/policies/status_command_policy.ts`](../src/application/policies/status_command_policy.ts) · [`src/application/policies/github_execution_admission_policy.ts`](../src/application/policies/github_execution_admission_policy.ts) · [`src/application/policies/workflow_queue_policy.ts`](../src/application/policies/workflow_queue_policy.ts) · [`src/application/usecases/actions/lifecycle_synchronization_context.ts`](../src/application/usecases/actions/lifecycle_synchronization_context.ts) · [`src/application/usecases/actions/synchronize_lifecycle_state_use_case.ts`](../src/application/usecases/actions/synchronize_lifecycle_state_use_case.ts) · [`src/application/usecases/steps/commit/bugbot/bugbot_review_operation_context.ts`](../src/application/usecases/steps/commit/bugbot/bugbot_review_operation_context.ts) · [`src/application/policies/semantic_result_publication_policy.ts`](../src/application/policies/semantic_result_publication_policy.ts) · [`src/application/usecases/steps/common/status_card_publication_workflow.ts`](../src/application/usecases/steps/common/status_card_publication_workflow.ts) · [`src/application/usecases/comment_automation_context.ts`](../src/application/usecases/comment_automation_context.ts) · [`src/application/usecases/issue_workflow_context.ts`](../src/application/usecases/issue_workflow_context.ts) · [`src/application/usecases/pull_request_workflow_context.ts`](../src/application/usecases/pull_request_workflow_context.ts) · [`src/application/usecases/push_single_action_contexts.ts`](../src/application/usecases/push_single_action_contexts.ts) · [`src/application/usecases/steps/common/check_permissions_workflow.ts`](../src/application/usecases/steps/common/check_permissions_workflow.ts) · [`src/application/usecases/steps/common/comment_language_translation_workflow.ts`](../src/application/usecases/steps/common/comment_language_translation_workflow.ts) · [`src/application/usecases/steps/common/think_workflow.ts`](../src/application/usecases/steps/common/think_workflow.ts) · [`src/application/usecases/steps/common/update_title_workflow.ts`](../src/application/usecases/steps/common/update_title_workflow.ts) · [`src/application/usecases/steps/common/project_content_link_workflow.ts`](../src/application/usecases/steps/common/project_content_link_workflow.ts) · [`src/application/usecases/steps/common/publish_resume_workflow.ts`](../src/application/usecases/steps/common/publish_resume_workflow.ts) · [`src/application/usecases/steps/common/store_configuration_use_case.ts`](../src/application/usecases/steps/common/store_configuration_use_case.ts) · [`src/infrastructure/composition/shared_capability_port_binding.ts`](../src/infrastructure/composition/shared_capability_port_binding.ts) · [`src/infrastructure/composition/lifecycle_capability_port_binding.ts`](../src/infrastructure/composition/lifecycle_capability_port_binding.ts) · [`src/infrastructure/composition/lifecycle_state_composition_root.ts`](../src/infrastructure/composition/lifecycle_state_composition_root.ts) · [`src/infrastructure/composition/push_single_action_capability_port_binding.ts`](../src/infrastructure/composition/push_single_action_capability_port_binding.ts) · [`src/infrastructure/composition/main_run_route_composition_root.ts`](../src/infrastructure/composition/main_run_route_composition_root.ts) · [`src/application/policies/configuration_persistence_policy.ts`](../src/application/policies/configuration_persistence_policy.ts) · [`src/application/ports/project_board_query_ports.ts`](../src/application/ports/project_board_query_ports.ts) · [`src/application/usecases/steps/commit/check_changes_issue_size_use_case.ts`](../src/application/usecases/steps/commit/check_changes_issue_size_use_case.ts) · [`src/application/usecases/steps/commit/check_changes_issue_size_workflow.ts`](../src/application/usecases/steps/commit/check_changes_issue_size_workflow.ts) · [`src/application/usecases/steps/commit/update_change_size_labels.ts`](../src/application/usecases/steps/commit/update_change_size_labels.ts) · [`src/data/repository/project/project_board_item_query.ts`](../src/data/repository/project/project_board_item_query.ts) · [`src/infrastructure/composition/change_size_project_membership_composition_root.ts`](../src/infrastructure/composition/change_size_project_membership_composition_root.ts) · [`src/manager/description/configuration_handler.ts`](../src/manager/description/configuration_handler.ts) · [`src/manager/description/configuration_markers.ts`](../src/manager/description/configuration_markers.ts) · [`src/data/repository/issue/issue_content_repository.ts`](../src/data/repository/issue/issue_content_repository.ts) · [`src/data/repository/github/github_error_policy.ts`](../src/data/repository/github/github_error_policy.ts) -- Tests: [`src/tooling/__tests__/coverage_budget_validator.test.ts`](../src/tooling/__tests__/coverage_budget_validator.test.ts) · [`src/__tests__/api.test.ts`](../src/__tests__/api.test.ts) · [`src/actions/__tests__/github_action.test.ts`](../src/actions/__tests__/github_action.test.ts) · [`src/actions/__tests__/github_action_completion.test.ts`](../src/actions/__tests__/github_action_completion.test.ts) · [`src/application/errors/__tests__/application_error.test.ts`](../src/application/errors/__tests__/application_error.test.ts) · [`src/application/policies/__tests__/application_error_message_catalog.test.ts`](../src/application/policies/__tests__/application_error_message_catalog.test.ts) · [`src/application/policies/__tests__/application_error_presentation_policy.test.ts`](../src/application/policies/__tests__/application_error_presentation_policy.test.ts) · [`src/application/policies/__tests__/bugbot_result_finding_state_projection_policy.test.ts`](../src/application/policies/__tests__/bugbot_result_finding_state_projection_policy.test.ts) · [`src/application/policies/__tests__/bugbot_telemetry_projection_policy.test.ts`](../src/application/policies/__tests__/bugbot_telemetry_projection_policy.test.ts) · [`src/application/policies/__tests__/action_summary_policy.test.ts`](../src/application/policies/__tests__/action_summary_policy.test.ts) · [`src/application/policies/__tests__/copilot_evidence_policy.test.ts`](../src/application/policies/__tests__/copilot_evidence_policy.test.ts) · [`src/application/policies/__tests__/lifecycle_state_policy.test.ts`](../src/application/policies/__tests__/lifecycle_state_policy.test.ts) · [`src/application/policies/__tests__/status_command_policy.test.ts`](../src/application/policies/__tests__/status_command_policy.test.ts) · [`src/application/usecases/actions/__tests__/lifecycle_synchronization_context.test.ts`](../src/application/usecases/actions/__tests__/lifecycle_synchronization_context.test.ts) · [`src/application/usecases/actions/__tests__/synchronize_lifecycle_state_use_case.test.ts`](../src/application/usecases/actions/__tests__/synchronize_lifecycle_state_use_case.test.ts) · [`src/application/usecases/actions/__tests__/lifecycle_event_replay.integration.test.ts`](../src/application/usecases/actions/__tests__/lifecycle_event_replay.integration.test.ts) · [`src/architecture/__tests__/execution_import_ratchet.test.ts`](../src/architecture/__tests__/execution_import_ratchet.test.ts) · [`src/architecture/__tests__/raw_error_logging_boundaries.test.ts`](../src/architecture/__tests__/raw_error_logging_boundaries.test.ts) · [`src/application/usecases/steps/commit/bugbot/__tests__/bugbot_review_operation_context.test.ts`](../src/application/usecases/steps/commit/bugbot/__tests__/bugbot_review_operation_context.test.ts) · [`src/application/usecases/steps/common/__tests__/shared_capability_context_projection.test.ts`](../src/application/usecases/steps/common/__tests__/shared_capability_context_projection.test.ts) · [`src/application/usecases/steps/common/__tests__/publish_resume_use_case.test.ts`](../src/application/usecases/steps/common/__tests__/publish_resume_use_case.test.ts) · [`src/infrastructure/composition/__tests__/shared_capability_port_binding.test.ts`](../src/infrastructure/composition/__tests__/shared_capability_port_binding.test.ts) · [`src/application/usecases/__tests__/issue_pull_request_context_projection.test.ts`](../src/application/usecases/__tests__/issue_pull_request_context_projection.test.ts) · [`src/infrastructure/composition/__tests__/lifecycle_capability_port_binding.test.ts`](../src/infrastructure/composition/__tests__/lifecycle_capability_port_binding.test.ts) · [`src/infrastructure/composition/__tests__/lifecycle_state_composition_root.test.ts`](../src/infrastructure/composition/__tests__/lifecycle_state_composition_root.test.ts) · [`src/application/usecases/__tests__/push_single_action_contexts.test.ts`](../src/application/usecases/__tests__/push_single_action_contexts.test.ts) · [`src/application/usecases/__tests__/single_action_workflow.test.ts`](../src/application/usecases/__tests__/single_action_workflow.test.ts) · [`src/infrastructure/composition/__tests__/push_single_action_capability_port_binding.test.ts`](../src/infrastructure/composition/__tests__/push_single_action_capability_port_binding.test.ts) · [`src/application/usecases/execution/__tests__/resolve_github_execution_admission_use_case.test.ts`](../src/application/usecases/execution/__tests__/resolve_github_execution_admission_use_case.test.ts) · [`src/application/usecases/workflow/__tests__/wait_for_previous_workflow_runs_use_case.test.ts`](../src/application/usecases/workflow/__tests__/wait_for_previous_workflow_runs_use_case.test.ts) · [`src/application/policies/__tests__/configuration_persistence_policy.test.ts`](../src/application/policies/__tests__/configuration_persistence_policy.test.ts) · [`src/application/usecases/steps/commit/__tests__/check_changes_issue_size_use_case.test.ts`](../src/application/usecases/steps/commit/__tests__/check_changes_issue_size_use_case.test.ts) · [`src/application/usecases/steps/commit/__tests__/update_change_size_labels.test.ts`](../src/application/usecases/steps/commit/__tests__/update_change_size_labels.test.ts) · [`src/data/repository/project/__tests__/project_board_visibility_recovery.test.ts`](../src/data/repository/project/__tests__/project_board_visibility_recovery.test.ts) · [`src/infrastructure/composition/__tests__/change_size_project_membership_composition_root.test.ts`](../src/infrastructure/composition/__tests__/change_size_project_membership_composition_root.test.ts) · [`src/manager/description/__tests__/configuration_recovery.test.ts`](../src/manager/description/__tests__/configuration_recovery.test.ts) · [`src/manager/description/__tests__/configuration_handler.test.ts`](../src/manager/description/__tests__/configuration_handler.test.ts) · [`src/data/repository/issue/__tests__/issue_content_repository.test.ts`](../src/data/repository/issue/__tests__/issue_content_repository.test.ts) -- User documentation: [`docs/overview.mdx`](../docs/overview.mdx) · [`docs/bugbot/programmatic-api.mdx`](../docs/bugbot/programmatic-api.mdx) · [`docs/security-operations/operations/error-reference.mdx`](../docs/security-operations/operations/error-reference.mdx) · [`docs/security-operations/operations/troubleshooting.mdx`](../docs/security-operations/operations/troubleshooting.mdx) · [`docs/bugbot/quality-observability.mdx`](../docs/bugbot/quality-observability.mdx) · [`docs/bugbot/failure-scenarios.mdx`](../docs/bugbot/failure-scenarios.mdx) · [`docs/development/architecture.mdx`](../docs/development/architecture.mdx) · [`docs/issues/assignees-and-projects.mdx`](../docs/issues/assignees-and-projects.mdx) · [`docs/configuration.mdx`](../docs/configuration.mdx) +- Core code: [`scripts/coverage-budgets.json`](../scripts/coverage-budgets.json) · [`scripts/validate-coverage-budgets.cjs`](../scripts/validate-coverage-budgets.cjs) · [`src/actions/main_run_lifecycle.ts`](../src/actions/main_run_lifecycle.ts) · [`src/actions/main_run_route.ts`](../src/actions/main_run_route.ts) · [`src/actions/github_action_completion.ts`](../src/actions/github_action_completion.ts) · [`src/api.ts`](../src/api.ts) · [`src/data/model/application_error.ts`](../src/data/model/application_error.ts) · [`src/application/errors/application_error.ts`](../src/application/errors/application_error.ts) · [`src/application/errors/application_error_context.ts`](../src/application/errors/application_error_context.ts) · [`src/application/policies/application_error_message_catalog.ts`](../src/application/policies/application_error_message_catalog.ts) · [`src/application/policies/application_error_presentation_policy.ts`](../src/application/policies/application_error_presentation_policy.ts) · [`src/application/policies/bugbot_result_finding_state_projection_policy.ts`](../src/application/policies/bugbot_result_finding_state_projection_policy.ts) · [`src/application/policies/bugbot_telemetry_projection_policy.ts`](../src/application/policies/bugbot_telemetry_projection_policy.ts) · [`src/application/policies/action_summary_policy.ts`](../src/application/policies/action_summary_policy.ts) · [`src/application/policies/copilot_evidence_policy.ts`](../src/application/policies/copilot_evidence_policy.ts) · [`src/application/policies/lifecycle_state_policy.ts`](../src/application/policies/lifecycle_state_policy.ts) · [`src/application/policies/status_command_policy.ts`](../src/application/policies/status_command_policy.ts) · [`src/application/policies/github_execution_admission_policy.ts`](../src/application/policies/github_execution_admission_policy.ts) · [`src/application/policies/workflow_queue_policy.ts`](../src/application/policies/workflow_queue_policy.ts) · [`src/application/usecases/actions/lifecycle_synchronization_context.ts`](../src/application/usecases/actions/lifecycle_synchronization_context.ts) · [`src/application/usecases/actions/synchronize_lifecycle_state_use_case.ts`](../src/application/usecases/actions/synchronize_lifecycle_state_use_case.ts) · [`src/application/usecases/steps/commit/bugbot/bugbot_review_operation_context.ts`](../src/application/usecases/steps/commit/bugbot/bugbot_review_operation_context.ts) · [`src/application/policies/semantic_result_publication_policy.ts`](../src/application/policies/semantic_result_publication_policy.ts) · [`src/application/usecases/steps/common/status_card_publication_workflow.ts`](../src/application/usecases/steps/common/status_card_publication_workflow.ts) · [`src/application/usecases/comment_automation_context.ts`](../src/application/usecases/comment_automation_context.ts) · [`src/application/usecases/issue_workflow_context.ts`](../src/application/usecases/issue_workflow_context.ts) · [`src/application/usecases/pull_request_workflow_context.ts`](../src/application/usecases/pull_request_workflow_context.ts) · [`src/application/usecases/push_single_action_contexts.ts`](../src/application/usecases/push_single_action_contexts.ts) · [`src/application/usecases/steps/common/check_permissions_workflow.ts`](../src/application/usecases/steps/common/check_permissions_workflow.ts) · [`src/application/usecases/steps/common/comment_language_translation_workflow.ts`](../src/application/usecases/steps/common/comment_language_translation_workflow.ts) · [`src/application/usecases/steps/common/think_workflow.ts`](../src/application/usecases/steps/common/think_workflow.ts) · [`src/application/usecases/steps/common/update_title_workflow.ts`](../src/application/usecases/steps/common/update_title_workflow.ts) · [`src/application/usecases/steps/common/project_content_link_workflow.ts`](../src/application/usecases/steps/common/project_content_link_workflow.ts) · [`src/application/usecases/steps/common/publish_resume_workflow.ts`](../src/application/usecases/steps/common/publish_resume_workflow.ts) · [`src/application/usecases/steps/common/store_configuration_use_case.ts`](../src/application/usecases/steps/common/store_configuration_use_case.ts) · [`src/infrastructure/composition/shared_capability_port_binding.ts`](../src/infrastructure/composition/shared_capability_port_binding.ts) · [`src/infrastructure/composition/lifecycle_capability_port_binding.ts`](../src/infrastructure/composition/lifecycle_capability_port_binding.ts) · [`src/infrastructure/composition/lifecycle_state_composition_root.ts`](../src/infrastructure/composition/lifecycle_state_composition_root.ts) · [`src/infrastructure/composition/push_single_action_capability_port_binding.ts`](../src/infrastructure/composition/push_single_action_capability_port_binding.ts) · [`src/infrastructure/composition/main_run_route_composition_root.ts`](../src/infrastructure/composition/main_run_route_composition_root.ts) · [`src/application/policies/configuration_persistence_policy.ts`](../src/application/policies/configuration_persistence_policy.ts) · [`src/application/ports/project_board_query_ports.ts`](../src/application/ports/project_board_query_ports.ts) · [`src/application/usecases/steps/commit/check_changes_issue_size_use_case.ts`](../src/application/usecases/steps/commit/check_changes_issue_size_use_case.ts) · [`src/application/usecases/steps/commit/check_changes_issue_size_workflow.ts`](../src/application/usecases/steps/commit/check_changes_issue_size_workflow.ts) · [`src/application/usecases/steps/commit/update_change_size_labels.ts`](../src/application/usecases/steps/commit/update_change_size_labels.ts) · [`src/data/repository/project/project_board_item_query.ts`](../src/data/repository/project/project_board_item_query.ts) · [`src/infrastructure/composition/change_size_project_membership_composition_root.ts`](../src/infrastructure/composition/change_size_project_membership_composition_root.ts) · [`src/manager/description/configuration_handler.ts`](../src/manager/description/configuration_handler.ts) · [`src/manager/description/configuration_markers.ts`](../src/manager/description/configuration_markers.ts) · [`src/data/repository/issue/issue_content_repository.ts`](../src/data/repository/issue/issue_content_repository.ts) · [`src/data/repository/github/github_error_policy.ts`](../src/data/repository/github/github_error_policy.ts) · [`src/application/policies/native_workflow_coordination_policy.ts`](../src/application/policies/native_workflow_coordination_policy.ts) · [`src/application/ports/workflow_run_ports.ts`](../src/application/ports/workflow_run_ports.ts) · [`src/application/usecases/workflow/wait_for_previous_workflow_runs_use_case.ts`](../src/application/usecases/workflow/wait_for_previous_workflow_runs_use_case.ts) · [`src/data/repository/workflow/active_previous_workflow_runs_repository.ts`](../src/data/repository/workflow/active_previous_workflow_runs_repository.ts) · [`src/data/repository/workflow/native_workflow_coordination_repository.ts`](../src/data/repository/workflow/native_workflow_coordination_repository.ts) · [`src/infrastructure/composition/workflow_queue_composition_root.ts`](../src/infrastructure/composition/workflow_queue_composition_root.ts) +- Tests: [`src/tooling/__tests__/coverage_budget_validator.test.ts`](../src/tooling/__tests__/coverage_budget_validator.test.ts) · [`src/__tests__/api.test.ts`](../src/__tests__/api.test.ts) · [`src/actions/__tests__/github_action.test.ts`](../src/actions/__tests__/github_action.test.ts) · [`src/actions/__tests__/github_action_completion.test.ts`](../src/actions/__tests__/github_action_completion.test.ts) · [`src/application/errors/__tests__/application_error.test.ts`](../src/application/errors/__tests__/application_error.test.ts) · [`src/application/policies/__tests__/application_error_message_catalog.test.ts`](../src/application/policies/__tests__/application_error_message_catalog.test.ts) · [`src/application/policies/__tests__/application_error_presentation_policy.test.ts`](../src/application/policies/__tests__/application_error_presentation_policy.test.ts) · [`src/application/policies/__tests__/bugbot_result_finding_state_projection_policy.test.ts`](../src/application/policies/__tests__/bugbot_result_finding_state_projection_policy.test.ts) · [`src/application/policies/__tests__/bugbot_telemetry_projection_policy.test.ts`](../src/application/policies/__tests__/bugbot_telemetry_projection_policy.test.ts) · [`src/application/policies/__tests__/action_summary_policy.test.ts`](../src/application/policies/__tests__/action_summary_policy.test.ts) · [`src/application/policies/__tests__/copilot_evidence_policy.test.ts`](../src/application/policies/__tests__/copilot_evidence_policy.test.ts) · [`src/application/policies/__tests__/lifecycle_state_policy.test.ts`](../src/application/policies/__tests__/lifecycle_state_policy.test.ts) · [`src/application/policies/__tests__/status_command_policy.test.ts`](../src/application/policies/__tests__/status_command_policy.test.ts) · [`src/application/usecases/actions/__tests__/lifecycle_synchronization_context.test.ts`](../src/application/usecases/actions/__tests__/lifecycle_synchronization_context.test.ts) · [`src/application/usecases/actions/__tests__/synchronize_lifecycle_state_use_case.test.ts`](../src/application/usecases/actions/__tests__/synchronize_lifecycle_state_use_case.test.ts) · [`src/application/usecases/actions/__tests__/lifecycle_event_replay.integration.test.ts`](../src/application/usecases/actions/__tests__/lifecycle_event_replay.integration.test.ts) · [`src/architecture/__tests__/execution_import_ratchet.test.ts`](../src/architecture/__tests__/execution_import_ratchet.test.ts) · [`src/architecture/__tests__/raw_error_logging_boundaries.test.ts`](../src/architecture/__tests__/raw_error_logging_boundaries.test.ts) · [`src/application/usecases/steps/commit/bugbot/__tests__/bugbot_review_operation_context.test.ts`](../src/application/usecases/steps/commit/bugbot/__tests__/bugbot_review_operation_context.test.ts) · [`src/application/usecases/steps/common/__tests__/shared_capability_context_projection.test.ts`](../src/application/usecases/steps/common/__tests__/shared_capability_context_projection.test.ts) · [`src/application/usecases/steps/common/__tests__/publish_resume_use_case.test.ts`](../src/application/usecases/steps/common/__tests__/publish_resume_use_case.test.ts) · [`src/infrastructure/composition/__tests__/shared_capability_port_binding.test.ts`](../src/infrastructure/composition/__tests__/shared_capability_port_binding.test.ts) · [`src/application/usecases/__tests__/issue_pull_request_context_projection.test.ts`](../src/application/usecases/__tests__/issue_pull_request_context_projection.test.ts) · [`src/infrastructure/composition/__tests__/lifecycle_capability_port_binding.test.ts`](../src/infrastructure/composition/__tests__/lifecycle_capability_port_binding.test.ts) · [`src/infrastructure/composition/__tests__/lifecycle_state_composition_root.test.ts`](../src/infrastructure/composition/__tests__/lifecycle_state_composition_root.test.ts) · [`src/application/usecases/__tests__/push_single_action_contexts.test.ts`](../src/application/usecases/__tests__/push_single_action_contexts.test.ts) · [`src/application/usecases/__tests__/single_action_workflow.test.ts`](../src/application/usecases/__tests__/single_action_workflow.test.ts) · [`src/infrastructure/composition/__tests__/push_single_action_capability_port_binding.test.ts`](../src/infrastructure/composition/__tests__/push_single_action_capability_port_binding.test.ts) · [`src/application/usecases/execution/__tests__/resolve_github_execution_admission_use_case.test.ts`](../src/application/usecases/execution/__tests__/resolve_github_execution_admission_use_case.test.ts) · [`src/application/usecases/workflow/__tests__/wait_for_previous_workflow_runs_use_case.test.ts`](../src/application/usecases/workflow/__tests__/wait_for_previous_workflow_runs_use_case.test.ts) · [`src/application/policies/__tests__/configuration_persistence_policy.test.ts`](../src/application/policies/__tests__/configuration_persistence_policy.test.ts) · [`src/application/usecases/steps/commit/__tests__/check_changes_issue_size_use_case.test.ts`](../src/application/usecases/steps/commit/__tests__/check_changes_issue_size_use_case.test.ts) · [`src/application/usecases/steps/commit/__tests__/update_change_size_labels.test.ts`](../src/application/usecases/steps/commit/__tests__/update_change_size_labels.test.ts) · [`src/data/repository/project/__tests__/project_board_visibility_recovery.test.ts`](../src/data/repository/project/__tests__/project_board_visibility_recovery.test.ts) · [`src/infrastructure/composition/__tests__/change_size_project_membership_composition_root.test.ts`](../src/infrastructure/composition/__tests__/change_size_project_membership_composition_root.test.ts) · [`src/manager/description/__tests__/configuration_recovery.test.ts`](../src/manager/description/__tests__/configuration_recovery.test.ts) · [`src/manager/description/__tests__/configuration_handler.test.ts`](../src/manager/description/__tests__/configuration_handler.test.ts) · [`src/data/repository/issue/__tests__/issue_content_repository.test.ts`](../src/data/repository/issue/__tests__/issue_content_repository.test.ts) · [`src/application/policies/__tests__/native_workflow_coordination_policy.test.ts`](../src/application/policies/__tests__/native_workflow_coordination_policy.test.ts) · [`src/data/repository/workflow/__tests__/native_workflow_coordination_repository.test.ts`](../src/data/repository/workflow/__tests__/native_workflow_coordination_repository.test.ts) · [`src/data/repository/workflow/__tests__/active_previous_workflow_runs_repository.test.ts`](../src/data/repository/workflow/__tests__/active_previous_workflow_runs_repository.test.ts) · [`src/actions/__tests__/common_action.test.ts`](../src/actions/__tests__/common_action.test.ts) · [`src/tooling/__tests__/validate_workflow_contract.test.ts`](../src/tooling/__tests__/validate_workflow_contract.test.ts) +- User documentation: [`docs/overview.mdx`](../docs/overview.mdx) · [`docs/bugbot/programmatic-api.mdx`](../docs/bugbot/programmatic-api.mdx) · [`docs/security-operations/operations/error-reference.mdx`](../docs/security-operations/operations/error-reference.mdx) · [`docs/security-operations/operations/troubleshooting.mdx`](../docs/security-operations/operations/troubleshooting.mdx) · [`docs/bugbot/quality-observability.mdx`](../docs/bugbot/quality-observability.mdx) · [`docs/bugbot/failure-scenarios.mdx`](../docs/bugbot/failure-scenarios.mdx) · [`docs/development/architecture.mdx`](../docs/development/architecture.mdx) · [`docs/issues/assignees-and-projects.mdx`](../docs/issues/assignees-and-projects.mdx) · [`docs/configuration.mdx`](../docs/configuration.mdx) · [`README.md`](../README.md) · [`docs/features.mdx`](../docs/features.mdx) · [`docs/pull-requests/workflow-setup.mdx`](../docs/pull-requests/workflow-setup.mdx) ### `architecture-quality-hardening` — Architecture quality and scalability hardening diff --git a/specs/catalog.json b/specs/catalog.json index 57b8cf4ab..9c42e0fd1 100644 --- a/specs/catalog.json +++ b/specs/catalog.json @@ -533,9 +533,9 @@ "id": "execution-lifecycle", "title": "Execution admission, queueing, routing, and result publication", "status": "implemented", - "scope": "Shared GitHub Action lifecycle from event admission through durable user-facing results", + "scope": "Shared GitHub Action lifecycle with pinned native resource/lane coordination proof, conservative predecessor fencing, routing and durable user-facing results", "owner": "Copilot maintainers", - "lastVerified": "2026-10-21", + "lastVerified": "2026-10-25", "specs": [ "specs/execution-admission-queue-and-publication.md", "specs/execution-error-and-context-hardening.md", @@ -604,7 +604,13 @@ "src/manager/description/configuration_handler.ts", "src/manager/description/configuration_markers.ts", "src/data/repository/issue/issue_content_repository.ts", - "src/data/repository/github/github_error_policy.ts" + "src/data/repository/github/github_error_policy.ts", + "src/application/policies/native_workflow_coordination_policy.ts", + "src/application/ports/workflow_run_ports.ts", + "src/application/usecases/workflow/wait_for_previous_workflow_runs_use_case.ts", + "src/data/repository/workflow/active_previous_workflow_runs_repository.ts", + "src/data/repository/workflow/native_workflow_coordination_repository.ts", + "src/infrastructure/composition/workflow_queue_composition_root.ts" ], "tests": [ "src/tooling/__tests__/coverage_budget_validator.test.ts", @@ -644,7 +650,12 @@ "src/infrastructure/composition/__tests__/change_size_project_membership_composition_root.test.ts", "src/manager/description/__tests__/configuration_recovery.test.ts", "src/manager/description/__tests__/configuration_handler.test.ts", - "src/data/repository/issue/__tests__/issue_content_repository.test.ts" + "src/data/repository/issue/__tests__/issue_content_repository.test.ts", + "src/application/policies/__tests__/native_workflow_coordination_policy.test.ts", + "src/data/repository/workflow/__tests__/native_workflow_coordination_repository.test.ts", + "src/data/repository/workflow/__tests__/active_previous_workflow_runs_repository.test.ts", + "src/actions/__tests__/common_action.test.ts", + "src/tooling/__tests__/validate_workflow_contract.test.ts" ], "documentation": [ "docs/overview.mdx", @@ -655,7 +666,10 @@ "docs/bugbot/failure-scenarios.mdx", "docs/development/architecture.mdx", "docs/issues/assignees-and-projects.mdx", - "docs/configuration.mdx" + "docs/configuration.mdx", + "README.md", + "docs/features.mdx", + "docs/pull-requests/workflow-setup.mdx" ] }, { diff --git a/specs/execution-admission-queue-and-publication.md b/specs/execution-admission-queue-and-publication.md index 89c5b7070..bcfc1f154 100644 --- a/specs/execution-admission-queue-and-publication.md +++ b/specs/execution-admission-queue-and-publication.md @@ -1,21 +1,23 @@ # Execution Admission, Queueing, Routing, and Result Publication -- Status: As-built baseline with implemented review-evidence ownership hardening +- Status: As-built baseline with review-evidence and native coordination hardening - Date: 2026-09-15 -- Last verified: 2026-09-15 on `develop` plus PR workflow UX implementation branch +- Last verified: 2026-10-25; issue #692 coordination regression verification - Owners: Copilot maintainers - Scope: the shared GitHub Action lifecycle from an incoming event to visible results and persisted execution state - Related issues/PRs: architecture quality and scalability hardening SDD; [PR #363](https://github.com/vypdev/copilot/pull/363); - [PR #378](https://github.com/vypdev/copilot/pull/378); historic motivation + [PR #378](https://github.com/vypdev/copilot/pull/378); + [issue #692](https://github.com/vypdev/copilot/issues/692); historic motivation before that live evidence is not recoverable from repository evidence - Required review gates: product UX, architecture, testing, documentation, security/operations - Open decisions blocking readiness: none for the baseline; see known debt and limitations ## 1. Executive summary -Copilot admits an event before expensive setup, serializes mutation-capable runs -per workflow, resolves one application route, and publishes a bounded result to +Copilot admits an event before expensive setup, verifies native resource/lane +coordination or serializes mutation-capable runs per workflow, resolves one +application route, and publishes a bounded result to the relevant GitHub conversation and Job Summary. A run created by the account behind the workflow PAT is ignored unless it is an explicit valid single action. The native `Copilot / Review` name is reserved for results carrying exactly one @@ -49,8 +51,10 @@ create inconsistent authorization and failure behavior. 2. Bot-authored normal events are discarded; valid explicit single actions pass. 3. Only agent roles reachable by the event are prepared, subject to `ai-members-only` authorization. -4. `mainRun` waits for older active runs of the same workflow, except the - dedicated failure-comment action. +4. Before setup, `mainRun` verifies the executing job's native resource/lane + coordination as specified in §6.4. Without that proof it waits for older + active runs of the same workflow. The dedicated failure-comment action stays + runnable when queue verification fails. 5. Setup restores issue, branch, and durable operation state; the route policy selects exactly one of issue, issue-comment, PR, review-comment, push, or single-action. @@ -69,7 +73,8 @@ create inconsistent authorization and failure behavior. - Observed behavior: `src/actions/github_action.ts`, `common_action.ts`, `main_run_lifecycle.ts`, `main_run_route.ts`, and `github_action_completion.ts`. -- Intentional contract: early bot-loop admission, workflow-scoped serialization, +- Intentional contract: early bot-loop admission, verified native resource/lane + coordination with workflow-scoped serialization as the conservative fallback, single-route ownership, sanitized publication, and fail-closed errors. - Known debt and limitations: queue state is polled and bounded by a 90-minute internal budget; optional Check Run publication is best-effort; 26 production @@ -83,8 +88,10 @@ create inconsistent authorization and failure behavior. [`execution-error-and-context-hardening.md`](./execution-error-and-context-hardening.md), under the sequencing and cross-cutting gates in [`architecture-quality-and-scalability-hardening.md`](./architecture-quality-and-scalability-hardening.md). - Changes to queue ownership or durable event processing beyond that proposal - require a separate design; they are not part of this baseline. + Issue #692 corrects a verified scheduler inversion: a later PR job occupied a + shared runner while waiting for an older queued job for a different PR. That + predecessor needed a runner from the same finite pool. The solution below + recognizes the existing native lanes rather than increasing the wait budget. ## 3. Actors, surfaces, and terminology @@ -104,7 +111,8 @@ presentation adapters decide where that fact appears. ### 4.1 Goals 1. Every accepted event MUST resolve to at most one top-level route. -2. Mutation-capable workflow runs MUST wait for older active runs or fail. +2. Mutation-capable workflow runs MUST have verified native resource/lane + coordination, wait for older active workflow runs, or fail before setup. 3. Results MUST remain understandable without raw logs. 4. A named native Check MUST represent only its semantic capability; a metadata result MUST NOT supersede a same-head review in GitHub's latest-by-name view. @@ -133,7 +141,7 @@ presentation adapters decide where that fact appears. | Stage | Pre-baseline understanding | As-built contract | Effect | |---|---|---|---| | Admission | implicit in entrypoint | explicit identity decision | bot loops stop early | -| Queue | implementation detail | fail-closed workflow gate | mutations do not overlap | +| Queue | every run polls the entire workflow | verify native lane before setup; otherwise fail-closed workflow gate | unrelated PRs do not hold runners waiting for each other | | Routing | spread across handlers | one route policy | predictable ownership | | Completion | comments only were assumed | route-owned comment when applicable + summary + optional check + state | evidence is inspectable without duplicate discussion noise | | Review evidence | every PR completion could reuse the Review name | exact-head telemetry owns Review; metadata keeps workflow/summary only | latest-by-name remains truthful | @@ -176,6 +184,60 @@ neutral. Duplicate events are tolerated through route-specific idempotency and persisted markers; this shared layer does not itself deduplicate GitHub deliveries. +### 6.4 Native coordination recognition — issue #692 + +The recommended installed Commit, PR analysis and PR review-state jobs already +declare native job concurrency. The runtime MUST recognize that exact contract +before setup, without a user-controlled bypass input and without enumerating +older runs when native coordination is proven. + +```text +Validate run/workflow identity + -> eligible normal event + pinned workflow/job metadata? + -> read executing workflow at GITHUB_WORKFLOW_SHA + -> exact job concurrency proven? -> setup -> existing guarded route + -> custom/legacy shape? -> existing predecessor queue -> setup + -> no eligible metadata? -> existing predecessor queue -> setup + -> provider/parse error or deadline -> fail before setup +``` + +Text equivalent: native proof delegates coordination to GitHub; missing proof +retains the predecessor fence; errors never grant permission to execute. + +| Normal event/resource | Required literal job group | Native cancellation | +|---|---|---| +| Branch `push` | `copilot-push-${{ github.repository }}-${{ github.ref_name }}` | `true` | +| `pull_request` with positive safe-integer PR number | `copilot-pr-${{ github.repository }}-${{ github.event.pull_request.number }}-analysis` | `true` | +| `pull_request_review` with positive safe-integer PR number | `copilot-pr-${{ github.repository }}-${{ github.event.pull_request.number }}-review-state` | `true` | + +The entrypoint MUST use GitHub's workflow ref, workflow commit SHA and current +job ID, bound to the current repository, rather than a checked-out file, workflow +display name or filename allowlist. Single actions, missing/invalid metadata, +tag pushes, unrelated events, reusable or multi-job workflows, workflow-level concurrency and +nonmatching groups MUST retain the existing workflow queue. The source read is +limited to a normal YAML workflow file at a full 40-hex commit SHA, at most +256 KiB. Parsing uses the JSON schema and executes no expressions or tags. +Recognition requires one direct job, preventing a reusable callee's job ID from +being mistaken for another protected job in the caller's workflow context. +Missing/malformed provider content, YAML errors, provider errors and exhausted +retry/deadline budgets MUST fail closed through the existing sanitized queue +failure. There is no disk fallback or read from a moving branch. + +GitHub's native key owns serialization/replacement within the same resource and +lane; PR identity keeps two PRs sharing a head branch independent. No runtime +comparison of `head_branch` substitutes for PR identity. Existing head/base, +finding, permission, publication and deployment fences remain mandatory. +Durable issue/comment mutations and legacy/custom jobs retain active-status +pagination, same-workflow predecessor ordering, retries and the 90-minute bound. +The recognition read and any fallback queue share that absolute deadline. + +No extra comments, checks or labels are introduced. A proven job logs +`Native workflow resource/lane coordination verified; continuing before setup.` +Legacy waiting and sanitized failures keep their existing messages. The run +and Job Summary remain the operator navigation surfaces. Cancellation before +setup leaves no application mutation; cancellation after setup retains the +existing route recovery rules. + ## 7. User-facing configuration | Input | Type | Recommended default | Allowed values | Scope/persistence | @@ -190,6 +252,11 @@ jitter, admission rules, route precedence, and error-to-exit mapping are not configurable. Inputs use Action values; stored issue configuration restores operation facts but never secrets. +Native recognition adds no public configuration or persisted state. Recommended +setup uses the three exact job groups in §6.4; a custom workflow without those +groups is a supported conservative alternative. Missing or future metadata +selects the existing queue, never a permissive mode. + ## 8. Clean Architecture design | Boundary | Owns | Must not own/import | @@ -216,6 +283,15 @@ application use cases decide; provider adapters satisfy ports; renderers expose sanitized results. Architecture and cycle tests MUST prevent inward layers from importing concrete infrastructure. +The entrypoint projects immutable workflow/job/event metadata before setup. The +waiting use case asks a semantic native-coordination query port once, then either +continues or uses its existing predecessor port. The GitHub adapter reads and +parses the pinned workflow; a pure application policy recognizes the exact group +and cancellation contract. Only infrastructure owns YAML/Octokit and environment +access stays at the entrypoint. Architecture import tests, workflow parser +contracts and the integrated scheduler-inversion regression enforce these +boundaries. No provider DTO or `Execution` dependency enters the pure policy. + ## 9. UI/UX and content contract The first view MUST say status, completed facts, next step, action, impact, and @@ -285,13 +361,22 @@ as `invalid`, never as zero findings. ## 13. Compatibility, migration, rollout, and rollback -This document records the repository behavior at the audit commit. There are no -installed users or real in-flight markers to preserve. The linked hardening work -therefore makes a clean cut: removed API/marker/result shapes are rejected and -no compatibility parser, migration, deprecation window, or dual reader ships. -After first real use, already completed provider mutations are never hidden or -rewound; incompatible future state fails closed and recovery starts from -inspectable provider facts. +For issue #692, consumers with the current native groups need only the fixed +runtime; workflow changes are unnecessary. Older/custom workflows keep the +queue. Roll out through a reviewed PR and the normal release process. A run +already waiting in an older runtime cannot adopt the fix in place; inspect its +predecessors and rerun after the fix is available. Changing concurrency keys +during an active run may overlap an old key, so existing freshness/idempotency +guards remain essential. Rollback restores the earlier runtime and its global +wait behavior; it does not undo provider mutations. Live consumer cancellation, +deployment and release are outside this issue's implementation authorization. + +The native coordination increment changes no installed input or durable marker +schema. Existing production consumers retain their workflow groups and operation +state. Already completed provider mutations are never hidden or rewound; +incompatible state fails closed and recovery starts from inspectable provider +facts. The historical evidence-schema cutover remains documented in the linked +hardening specifications; it is not a migration introduced by issue #692. ## 14. Testing strategy and numeric budget @@ -311,6 +396,20 @@ Tests use fake clocks/randomness/providers and no live waits. Golden Markdown needs semantic assertions. Human evidence remains required for desktop/mobile, light/dark, and screen-reader order. +Issue #692 adds a minimum **24 distinct cases**, derived from the new proof +boundary: **12 pure policy/context** cases (three lanes, same-owner stability, +invalid resources/metadata, single actions and custom groups), **6 adapter** +cases (pinned contents, malformed content/YAML, endpoint errors, safe fallback +and bounds), **4 orchestration/integration** cases (scheduler inversion before +setup, retained mutation fence, native failure before setup and deadline), and +**2 shipped/setup workflow replays** proving different-PR independence and +same-resource/lane stability. Existing predecessor pagination/retry/error and +workflow mutant cases remain required. New pure policy targets at least 95% +branch coverage; changed adapters/use cases target at least 90% lines. Fake +providers, clocks and delays exercise the race without live runner sleeps. +No new graphical UI exists, so human visual captures are not needed for this +increment; native log copy is checked semantically. + ## 15. Documentation and discoverability | Audience | Artifact | Contract | @@ -320,11 +419,16 @@ light/dark, and screen-reader order. | Operator | troubleshooting | timeout, partial publication, retry | | Contributor | architecture + this SDD | boundaries and route ownership | +Issue #692 updates `docs/features.mdx`, `docs/pull-requests/workflow-setup.mdx` +and the README with native proof, conservative custom fallback, pinned-content +read permissions, shared-runner recovery and unchanged safety guards. + ## 16. Acceptance scenarios 1. Given a PAT-authored normal event, the run exits before queue or mutation. 2. Given a valid explicit single action from that account, it executes. -3. Given older active runs, the current run waits with bounded jitter. +3. Given older active runs without native coordination proof, the current run + waits with bounded jitter. 4. Given unavailable workflow identity on GitHub Actions, queueing fails closed. 5. Given a known event, exactly one route owns it. 6. Given successful core work and failed optional check publication, the summary @@ -350,6 +454,20 @@ light/dark, and screen-reader order. telemetry, the telemetry-set and finding-state projections are invalid; no Review Check is published and every other current-state consumer fails or blocks instead of reporting success. +16. Given an older queued PR A and a later executing PR B on a finite shared + pool, a proven native job reaches setup without enumerating predecessors + or waiting; the shipped and setup workflow fixtures reproduce this. +17. Given successive events for the same PR/lane, GitHub's exact group stays + stable; different PRs and analysis/review-state lanes remain independent. +18. Given a legacy/custom job, missing metadata, tag push or single action, the + original predecessor fence blocks setup until older active runs finish. +19. Given malformed source, unreadable workflow content or an expired proof + deadline, setup/routes do not run and raw provider diagnostics are absent + from the public queue failure. +20. Given a misleading filename, different checkout or moving branch, native + recognition uses only the executing repository/workflow SHA/job and rejects + nonmatching contracts. Policy and adapter tests enforce the inspection bound + and prohibit executable YAML tags. ## 17. Requirements traceability @@ -357,6 +475,7 @@ light/dark, and screen-reader order. |---|---|---|---| | admission invariants | admission policy/use case | admission tests | overview | | serialization | queue policy/use case/adapters | queue + workflow tests | troubleshooting | +| §6.4 native proof and conservative fallback | native coordination policy/port/adapter + waiting use case | policy/context, pinned-content, scheduler-inversion and failure/deadline cases; shipped/setup replays | features and PR workflow setup | | one route | route policy/dispatcher | route tests | architecture | | safe publication | completion/presentation policies | completion tests | overview | | metadata noise bound | explicit generic-comment publication mode | metadata-edit context/use-case/completion negatives | workflow setup and troubleshooting | @@ -374,6 +493,10 @@ light/dark, and screen-reader order. ## 19. Definition of Done +- [x] Issue #692: the additional 24-case budget, policy/adapter coverage, + scheduler inversion, same-resource contracts, sanitized failures, + documentation, typecheck/lint, workflow and specification validation pass. + - [x] Normative changes have acceptance and traceability. - [x] Architecture, workflow, catalog, and cycle checks pass. - [x] The 70-case risk budget and repository coverage gates pass; both shared @@ -387,6 +510,13 @@ light/dark, and screen-reader order. ## 20. References and decisions - Primary sources: catalogued code, workflows, tests, and documentation. +- Issue #692 primary provider contracts: [native job concurrency](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency), + [workflow SHA and job metadata](https://docs.github.com/en/actions/reference/workflows-and-actions/variables), + and [repository content at a ref](https://docs.github.com/en/rest/repos/contents#get-repository-content). + Decision: prove the executing job's existing native contract, then delegate + to GitHub. Reject a filename-only allowlist, a caller bypass flag, checked-out + YAML, branch-name predecessor filtering and larger timeouts: none establishes + safe PR identity and native fencing before setup. - Related specs: release orchestration, merge-queue readiness, Bugbot reconciliation. - Planned hardening: `execution-error-and-context-hardening.md` owns the semantic error boundary and `Execution` context replacement; the architecture hardening diff --git a/src/actions/__tests__/common_action.test.ts b/src/actions/__tests__/common_action.test.ts index 3693afd2e..3f000fa4f 100644 --- a/src/actions/__tests__/common_action.test.ts +++ b/src/actions/__tests__/common_action.test.ts @@ -20,6 +20,13 @@ import type { Execution } from '../../data/model/execution'; import type { SynchronizeAgentActivityUseCase } from '../../application/usecases/actions/synchronize_agent_activity_use_case'; import { Result } from '../../data/model/result'; import { logInfo } from '../../utils/logger'; +import { readFileSync } from 'node:fs'; +import { buildPreviousWorkflowRunsQuery } from '../main_run_lifecycle'; +import * as issueWorkflowRuntimePolicy from '../../domain/issue_workflow_runtime_policy'; +import { WaitForPreviousWorkflowRunsUseCase } from '../../application/usecases/workflow/wait_for_previous_workflow_runs_use_case'; +import { NativeWorkflowCoordinationRepository } from '../../data/repository/workflow/native_workflow_coordination_repository'; +import { ActivePreviousWorkflowRunsRepository } from '../../data/repository/workflow/active_previous_workflow_runs_repository'; +import type { PreviousWorkflowRunsQuery } from '../../application/ports/workflow_run_ports'; jest.mock('@actions/core', () => ({ setFailed: jest.fn(), @@ -244,6 +251,8 @@ const originalRunId = process.env.GITHUB_RUN_ID; const originalWorkflow = process.env.GITHUB_WORKFLOW; const originalWorkflowRef = process.env.GITHUB_WORKFLOW_REF; const originalGithubActions = process.env.GITHUB_ACTIONS; +const nativeEnvironment = ['GITHUB_WORKFLOW_SHA', 'GITHUB_JOB', 'GITHUB_EVENT_NAME', 'GITHUB_REF'] as const; +const originalNativeEnvironment = nativeEnvironment.map(name => [name, process.env[name]] as const); function restoreEnvironmentVariable(name: string, value: string | undefined): void { if (value === undefined) { @@ -262,6 +271,7 @@ describe('mainRun', () => { delete process.env.GITHUB_WORKFLOW; delete process.env.GITHUB_WORKFLOW_REF; delete process.env.GITHUB_ACTIONS; + for (const name of nativeEnvironment) delete process.env[name]; mockWaitForPreviousWorkflowRunsInvoke.mockResolvedValue(undefined); mockSingleActionInvoke.mockResolvedValue([]); mockIssueCommentInvoke.mockResolvedValue([]); @@ -280,6 +290,7 @@ describe('mainRun', () => { restoreEnvironmentVariable('GITHUB_WORKFLOW', originalWorkflow); restoreEnvironmentVariable('GITHUB_WORKFLOW_REF', originalWorkflowRef); restoreEnvironmentVariable('GITHUB_ACTIONS', originalGithubActions); + for (const [name, value] of originalNativeEnvironment) restoreEnvironmentVariable(name, value); }); it('delegates setup to the composed use case and clears accumulated logs', async () => { @@ -326,6 +337,139 @@ describe('mainRun', () => { }); }); + function nativeRunEnvironment() { + Object.assign(process.env, { + GITHUB_ACTIONS: 'true', GITHUB_RUN_ID: '200', GITHUB_EVENT_NAME: 'pull_request', + GITHUB_WORKFLOW_REF: 'org/repo/.github/workflows/copilot_pull_request.yml@refs/heads/main', + GITHUB_WORKFLOW_SHA: 'a'.repeat(40), GITHUB_JOB: 'copilot-pull-requests', + }); + } + + it.each(['.github/workflows', 'setup/workflows'])( + 'replays scheduler inversion before setup using the native contract in %s', async directory => { + nativeRunEnvironment(); + const order: string[] = []; + const source = readFileSync(`${directory}/copilot_pull_request.yml`, 'utf8'); + const getContent = jest.fn(async () => { + order.push('proof'); + return { data: { type: 'file', encoding: 'base64', content: Buffer.from(source).toString('base64') } }; + }); + // An older PR A is queued and cannot acquire this runner while B holds it. + const iterator = jest.fn(async function* () { + yield { data: { workflow_runs: [{ id: 199, status: 'queued' }] } }; + }); + const delay = { wait: jest.fn() }; + const clock = { nowMilliseconds: () => 0 }; + const native = new NativeWorkflowCoordinationRepository({ rest: { repos: { getContent } } }, { + delayPort: delay, clock, random: { next: () => 0.5 }, + }); + const predecessors = new ActivePreviousWorkflowRunsRepository({ + rest: { actions: { listWorkflowRuns: jest.fn() } }, paginate: { iterator }, + }); + mockWaitForPreviousWorkflowRunsInvoke.mockImplementation((query: PreviousWorkflowRunsQuery) => + new WaitForPreviousWorkflowRunsUseCase(predecessors, delay, { + noActivePreviousRuns: jest.fn(), waitingForPreviousRuns: jest.fn(), + }, undefined, clock, undefined, native).invoke(query)); + mockSetupExecutionInvoke.mockImplementation(async context => { + order.push('setup'); return unresolvedSetupResult(context); + }); + await runMain(mockExecution({ isPullRequest: true, eventName: 'pull_request', inputs: { pull_request: { number: 22 } } })); + expect(order).toEqual(['proof', 'setup']); + expect(iterator).not.toHaveBeenCalled(); + expect(delay.wait).not.toHaveBeenCalled(); + expect(mockPullRequestInvoke).toHaveBeenCalledTimes(1); + expect(getContent).toHaveBeenCalledWith({ owner: 'org', repo: 'repo', + path: '.github/workflows/copilot_pull_request.yml', ref: 'a'.repeat(40) }); + }, + ); + + it('retains the mutation fence before setup for a custom job without native coordination', async () => { + nativeRunEnvironment(); + const order: string[] = []; + let predecessorActive = true; + const iterator = jest.fn(async function* () { + yield { data: { workflow_runs: predecessorActive ? [{ id: 199, status: 'queued' }] : [] } }; + }); + const delay = { wait: jest.fn(async () => { order.push('wait'); predecessorActive = false; }) }; + const clock = { nowMilliseconds: () => 0 }; + const native = new NativeWorkflowCoordinationRepository({ rest: { repos: { + getContent: jest.fn().mockResolvedValue({ data: { type: 'file', encoding: 'base64', + content: Buffer.from('jobs: { copilot-pull-requests: { steps: [] } }').toString('base64') } }), + } } }, { delayPort: delay, clock, random: { next: () => 0.5 } }); + const predecessors = new ActivePreviousWorkflowRunsRepository({ + rest: { actions: { listWorkflowRuns: jest.fn() } }, paginate: { iterator }, + }, undefined, undefined, clock); + mockWaitForPreviousWorkflowRunsInvoke.mockImplementation((query: PreviousWorkflowRunsQuery) => + new WaitForPreviousWorkflowRunsUseCase(predecessors, delay, { + noActivePreviousRuns: jest.fn(), waitingForPreviousRuns: jest.fn(), + }, undefined, clock, { next: () => 0.5 }, native).invoke(query)); + mockSetupExecutionInvoke.mockImplementation(async context => { + expect(predecessorActive).toBe(false); order.push('setup'); return unresolvedSetupResult(context); + }); + await runMain(mockExecution({ inputs: { pull_request: { number: 21 } } })); + expect(order).toEqual(['wait', 'setup']); + }); + + it('fails before setup with sanitized diagnostics when native proof cannot be read', async () => { + nativeRunEnvironment(); + const native = new NativeWorkflowCoordinationRepository({ rest: { repos: { + getContent: jest.fn().mockRejectedValue({ status: 403, message: 'secret-provider-body' }), + } } }, { delayPort: { wait: jest.fn() }, clock: { nowMilliseconds: () => 0 }, random: { next: () => 0.5 } }); + mockWaitForPreviousWorkflowRunsInvoke.mockImplementation((query: PreviousWorkflowRunsQuery) => + new WaitForPreviousWorkflowRunsUseCase({ countActivePreviousRuns: jest.fn() }, { wait: jest.fn() }, { + noActivePreviousRuns: jest.fn(), waitingForPreviousRuns: jest.fn(), + }, undefined, { nowMilliseconds: () => 0 }, undefined, native).invoke(query)); + await expect(runMain(mockExecution({ inputs: { pull_request: { number: 21 } } }))) + .rejects.toThrow('Workflow queue check failed; sequential execution was not bypassed.'); + expect(mockSetupExecutionInvoke).not.toHaveBeenCalled(); + expect(mockPullRequestInvoke).not.toHaveBeenCalled(); + expect(logger.logError).not.toHaveBeenCalledWith(expect.stringContaining('secret-provider-body')); + }); + + it('projects only GitHub workflow metadata and omits native delegation for single actions', () => { + nativeRunEnvironment(); + const repository = { owner: 'org', repo: 'repo' }; + const query = buildPreviousWorkflowRunsQuery(repository, { isSingleAction: false, pullRequestNumber: 21 }); + expect(query.nativeCoordination).toMatchObject({ workflowRevision: 'a'.repeat(40), + jobId: 'copilot-pull-requests', eventName: 'pull_request', pullRequestNumber: 21 }); + expect(Object.isFrozen(query.nativeCoordination)).toBe(true); + expect(buildPreviousWorkflowRunsQuery(repository, { isSingleAction: true }).nativeCoordination).toBeUndefined(); + }); + + it('refuses admission before setup when pinned metadata lacks the workflow identity', async () => { + nativeRunEnvironment(); + delete process.env.GITHUB_WORKFLOW_REF; + delete process.env.GITHUB_EVENT_NAME; + + const query = buildPreviousWorkflowRunsQuery({ owner: 'org', repo: 'repo' }, { isSingleAction: false }); + expect(query.nativeCoordination).toMatchObject({ workflowRef: '', eventName: '' }); + expect(query.workflowIdentifier).toBeUndefined(); + await expect(runMain(mockExecution())).rejects.toThrow('GitHub workflow identifier is unavailable'); + expect(createWaitForPreviousWorkflowRunsUseCase).not.toHaveBeenCalled(); + expect(mockSetupExecutionInvoke).not.toHaveBeenCalled(); + }); + + it('retains the predecessor fence when runner metadata has no event name', async () => { + nativeRunEnvironment(); + delete process.env.GITHUB_EVENT_NAME; + const countActivePreviousRuns = jest.fn().mockResolvedValue(0); + const getContent = jest.fn(); + const clock = { nowMilliseconds: () => 0 }; + const native = new NativeWorkflowCoordinationRepository({ rest: { repos: { getContent } } }, { + clock, delayPort: { wait: jest.fn() }, random: { next: () => 0.5 }, + }); + mockWaitForPreviousWorkflowRunsInvoke.mockImplementation(query => + new WaitForPreviousWorkflowRunsUseCase({ countActivePreviousRuns }, { wait: jest.fn() }, { + noActivePreviousRuns: jest.fn(), waitingForPreviousRuns: jest.fn(), providerRetry: jest.fn(), + }, undefined, clock, undefined, native).invoke(query)); + + await runMain(mockExecution({ isPullRequest: true, inputs: { pull_request: { number: 21 } } })); + + expect(getContent).not.toHaveBeenCalled(); + expect(countActivePreviousRuns).toHaveBeenCalledTimes(1); + expect(mockSetupExecutionInvoke).toHaveBeenCalledTimes(1); + }); + it('does not queue the issue-comment single action so a failed queue gate can still report', async () => { process.env.GITHUB_ACTIONS = 'true'; delete process.env.GITHUB_RUN_ID; @@ -794,6 +938,71 @@ describe('mainRun', () => { expect(execution.labels.currentIssueLabels).toEqual(['state:ready']); }); + it.each([ + undefined, + { labels: ['state:ready'] }, + { target: { kind: 'issue', number: 42 } }, + ])('leaves label caches intact when activity returns no complete patch (%j)', async outcome => { + mockAgentActivityStart.mockResolvedValue(outcome); + mockAgentActivityFinish.mockResolvedValue(outcome); + const execution = mockExecution({ + eventName: 'push', isPush: true, commit: { commits: [{ id: 'commit-1' }] }, + ai: { + getAgentConfiguration: jest.fn(() => ({ model: 'model' })), + getPullRequestDescriptionMode: jest.fn(() => 'disabled'), + }, + }); + + await runMainWithActivity(execution); + + expect(mockAgentActivityStart).toHaveBeenCalledTimes(1); + expect(mockAgentActivityFinish).toHaveBeenCalledTimes(1); + expect(mockCommitInvoke).toHaveBeenCalledWith(execution); + expect(execution.labels.currentIssueLabels).toEqual([]); + expect(execution.labels.currentPullRequestLabels).toEqual([]); + }); + + it.each([ + ['in-progress', false], + ['deploy', false], + ['unrelated', true], + ])('blocks explicit disabled-work intent for %s while leaving unrelated callbacks idle', async (labelAdded, success) => { + const execution = mockExecution({ + eventName: 'issues', isIssue: true, + issue: { labeled: true, labelAdded }, + }); + execution.labels.deploy = 'deploy'; + mockSetupExecutionInvoke.mockImplementation(async context => configuredSetupResult( + context, { status: 'disabled', kind: 'feature' }, + )); + + const results = await runMain(execution); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ id: 'IssueWorkflowAdmission', success, executed: false }); + expect(mockIssueInvoke).not.toHaveBeenCalled(); + }); + + it('renders a bounded fallback when an admission decision has no optional message', async () => { + const decision = jest.spyOn(issueWorkflowRuntimePolicy, 'decideIssueWorkflowRuntime') + .mockReturnValueOnce({ mode: 'noop' }); + try { + const results = await runMain(mockExecution()); + expect(results[0].steps).toEqual(['⏭️ Issue workflow admission stopped this run.']); + expect(mockIssueInvoke).not.toHaveBeenCalled(); + } finally { + decision.mockRestore(); + } + }); + + it('summarizes route results that omit optional step details', async () => { + const results = [{ id: 'push', success: true, executed: true }] as Result[]; + mockCommitInvoke.mockResolvedValue(results); + + expect(await runMain(mockExecution({ isPush: true }))).toEqual(results); + expect(logInfo).toHaveBeenCalledWith('Main run finished. Results: 1, total steps: 0.'); + }); + it.each(['help', 'status'])('dispatches deterministic %s without an agent activity mutation', async command => { const execution = mockExecution({ eventName: 'issue_comment', isIssue: true, diff --git a/src/actions/common_action.ts b/src/actions/common_action.ts index 9f36dda99..11a514405 100644 --- a/src/actions/common_action.ts +++ b/src/actions/common_action.ts @@ -59,10 +59,12 @@ export async function mainRun( logDebugInfo(`Event: ${execution.eventName}, actor: ${execution.actor}, repo: ${repository.owner}/${repository.repo}, debug: ${execution.debug}`); if (process.env.GITHUB_ACTIONS === 'true' && !execution.singleAction.isPublishIssueCommentAction) { - // Every GitHub workflow invocation queues before setup or route work so - // executions of the same workflow file cannot overlap mutations. A - // failure notification must remain runnable when that queue gate fails. - await waitForPreviousWorkflowRuns(execution.tokens.token, repository); + // Verify native resource/lane coordination or retain the predecessor + // fence before setup. Failure notifications must remain runnable. + await waitForPreviousWorkflowRuns(execution.tokens.token, repository, { + isSingleAction: execution.isSingleAction, + pullRequestNumber: execution.inputs?.pull_request?.number, + }); } const setupExecution = createSetupExecutionUseCase(latestTagQueryPort, { diff --git a/src/actions/main_run_lifecycle.ts b/src/actions/main_run_lifecycle.ts index 1fb40018e..881aa1771 100644 --- a/src/actions/main_run_lifecycle.ts +++ b/src/actions/main_run_lifecycle.ts @@ -28,13 +28,24 @@ export class WorkflowQueueFailureError extends Error { export function buildPreviousWorkflowRunsQuery( repository: RepositoryCoordinates, + event?: { readonly isSingleAction: boolean; readonly pullRequestNumber?: number }, ): PreviousWorkflowRunsQuery { const workflowIdentifier = resolveWorkflowIdentifier(process.env.GITHUB_WORKFLOW_REF); const query: PreviousWorkflowRunsQuery = { owner: repository.owner, repository: repository.repo, - currentRunId: Number.parseInt(process.env.GITHUB_RUN_ID ?? '', 10), + currentRunId: Number(process.env.GITHUB_RUN_ID ?? Number.NaN), ...(workflowIdentifier ? { workflowIdentifier } : {}), + ...(event && !event.isSingleAction && process.env.GITHUB_WORKFLOW_SHA && process.env.GITHUB_JOB ? { + nativeCoordination: Object.freeze({ + workflowRef: process.env.GITHUB_WORKFLOW_REF ?? '', + workflowRevision: process.env.GITHUB_WORKFLOW_SHA, + jobId: process.env.GITHUB_JOB, + eventName: process.env.GITHUB_EVENT_NAME ?? '', + ref: process.env.GITHUB_REF, + pullRequestNumber: event.pullRequestNumber, + }), + } : {}), }; return query; } @@ -42,9 +53,10 @@ export function buildPreviousWorkflowRunsQuery( export async function waitForPreviousWorkflowRuns( token: string, repository: RepositoryCoordinates, + event?: { readonly isSingleAction: boolean; readonly pullRequestNumber?: number }, ): Promise { - const query = buildPreviousWorkflowRunsQuery(repository); - if (process.env.GITHUB_ACTIONS === 'true' && !Number.isSafeInteger(query.currentRunId)) { + const query = buildPreviousWorkflowRunsQuery(repository, event); + if (process.env.GITHUB_ACTIONS === 'true' && (!Number.isSafeInteger(query.currentRunId) || query.currentRunId <= 0)) { throw new Error('GitHub workflow identity is unavailable; refusing to bypass sequential execution.'); } if (process.env.GITHUB_ACTIONS === 'true' && !query.workflowIdentifier) { diff --git a/src/application/policies/__tests__/native_workflow_coordination_policy.test.ts b/src/application/policies/__tests__/native_workflow_coordination_policy.test.ts new file mode 100644 index 000000000..6834f0e30 --- /dev/null +++ b/src/application/policies/__tests__/native_workflow_coordination_policy.test.ts @@ -0,0 +1,61 @@ +import { expectedNativeWorkflowGroup, hasNativeWorkflowCoordination } from '../native_workflow_coordination_policy'; +import type { PreviousWorkflowRunsQuery } from '../../ports/workflow_run_ports'; + +const group = 'copilot-pr-${{ github.repository }}-${{ github.event.pull_request.number }}-analysis'; +const native = { + workflowRef: 'org/repo/.github/workflows/custom.yml@refs/heads/main', + workflowRevision: 'a'.repeat(40), jobId: 'review', eventName: 'pull_request', pullRequestNumber: 21, +}; +const query: PreviousWorkflowRunsQuery = { + owner: 'org', repository: 'repo', currentRunId: 200, workflowIdentifier: 'custom.yml', nativeCoordination: native, +}; +const workflow = (concurrency: unknown = { group, 'cancel-in-progress': true }) => ({ + jobs: { review: { steps: [], concurrency } }, +}); + +describe('native workflow coordination policy', () => { + it.each([ + ['pull_request', undefined, 21, group], + ['pull_request_review', undefined, 21, group.replace('analysis', 'review-state')], + ['push', 'refs/heads/feature/work', undefined, 'copilot-push-${{ github.repository }}-${{ github.ref_name }}'], + ])('recognizes the exact %s lane with valid resource identity', (eventName, ref, pullRequestNumber, expected) => { + expect(expectedNativeWorkflowGroup({ ...query, nativeCoordination: { ...native, eventName, ref, pullRequestNumber } })).toBe(expected); + expect(hasNativeWorkflowCoordination(workflow({ group: expected, 'cancel-in-progress': true }), 'review', expected)).toBe(true); + }); + + it.each([ + { nativeCoordination: undefined }, + { currentRunId: Number.NaN }, { currentRunId: 0 }, + { workflowIdentifier: '../custom.yml' }, { workflowIdentifier: undefined }, + { nativeCoordination: { ...native, workflowRef: 'elsewhere/repo/.github/workflows/custom.yml@main' } }, + { nativeCoordination: { ...native, workflowRef: 'org/repo/.github/workflows/custom.yml@' } }, + { nativeCoordination: { ...native, workflowRevision: 'main' } }, + { nativeCoordination: { ...native, jobId: '' } }, + { nativeCoordination: { ...native, pullRequestNumber: 0 } }, + { nativeCoordination: { ...native, pullRequestNumber: undefined } }, + { nativeCoordination: { ...native, pullRequestNumber: 1.5 } }, + { nativeCoordination: { ...native, eventName: 'issues' } }, + { nativeCoordination: { ...native, eventName: 'push', ref: 'refs/tags/v3' } }, + { nativeCoordination: { ...native, eventName: 'push', ref: undefined } }, + { nativeCoordination: { ...native, eventName: 'push', ref: 'refs/heads/', pullRequestNumber: undefined } }, + ])('retains the conservative queue for invalid or unrelated metadata %j', patch => { + expect(expectedNativeWorkflowGroup({ ...query, ...patch })).toBeUndefined(); + }); + + it.each([ + undefined, null, [], {}, { concurrency: {} }, { jobs: [] }, { jobs: { review: null } }, + { jobs: { review: { uses: 'org/repo/workflow.yml@v1', concurrency: { group, 'cancel-in-progress': true } } } }, + { ...workflow(), concurrency: 'outer' }, + { jobs: { ...workflow().jobs, other: { uses: 'org/repo/workflow.yml@v1' } } }, + workflow('custom'), workflow({ group: 'custom', 'cancel-in-progress': true }), + workflow({ group, 'cancel-in-progress': false }), + workflow({ group, 'cancel-in-progress': '${{ true }}' }), + workflow({ group, 'cancel-in-progress': true, queue: 'max' }), + ])('does not infer native protection from a custom or incomplete workflow %j', value => { + expect(hasNativeWorkflowCoordination(value, 'review', group)).toBe(false); + }); + + it('accepts explicit single-pending native queue semantics', () => { + expect(hasNativeWorkflowCoordination(workflow({ group, 'cancel-in-progress': true, queue: 'single' }), 'review', group)).toBe(true); + }); +}); diff --git a/src/application/policies/native_workflow_coordination_policy.ts b/src/application/policies/native_workflow_coordination_policy.ts new file mode 100644 index 000000000..338c09c51 --- /dev/null +++ b/src/application/policies/native_workflow_coordination_policy.ts @@ -0,0 +1,43 @@ +import type { PreviousWorkflowRunsQuery } from '../ports/workflow_run_ports'; + +const PUSH_GROUP = 'copilot-push-${{ github.repository }}-${{ github.ref_name }}'; +const PR_GROUP = 'copilot-pr-${{ github.repository }}-${{ github.event.pull_request.number }}'; + +/** Closed recognition of the installed contract, without evaluating expressions. */ +export function expectedNativeWorkflowGroup(query: PreviousWorkflowRunsQuery): string | undefined { + const native = query.nativeCoordination; + if (!native || !Number.isSafeInteger(query.currentRunId) || query.currentRunId <= 0 + || !/^[\w.-]+\.ya?ml$/u.test(query.workflowIdentifier ?? '') + || !native.workflowRef.startsWith(`${query.owner}/${query.repository}/.github/workflows/${query.workflowIdentifier}@`) + || native.workflowRef.endsWith('@') + || !/^[a-f\d]{40}$/iu.test(native.workflowRevision) + || !/^[a-zA-Z_][\w-]*$/u.test(native.jobId)) return undefined; + + if (native.eventName === 'push' && native.ref?.startsWith('refs/heads/') + && native.ref.length > 'refs/heads/'.length) return PUSH_GROUP; + const pullRequestNumber = native.pullRequestNumber; + if (typeof pullRequestNumber !== 'number' || !Number.isSafeInteger(pullRequestNumber) || pullRequestNumber <= 0) return undefined; + if (native.eventName === 'pull_request') return `${PR_GROUP}-analysis`; + if (native.eventName === 'pull_request_review') return `${PR_GROUP}-review-state`; + return undefined; +} + +export function hasNativeWorkflowCoordination(workflow: unknown, jobId: string, expectedGroup: string): boolean { + const definition = record(workflow); + const jobs = record(definition?.jobs); + const job = record(jobs?.[jobId]); + const concurrency = record(job?.concurrency); + // Custom/reusable/workflow-level contracts retain conservative polling. + // A single direct job also avoids confusing a reusable callee's job ID + // with a different protected job in its caller's workflow context. + return definition?.concurrency === undefined && jobs !== undefined && Object.keys(jobs).length === 1 + && Array.isArray(job?.steps) + && job?.uses === undefined && concurrency?.group === expectedGroup + && concurrency?.['cancel-in-progress'] === true + && (concurrency.queue === undefined || concurrency.queue === 'single'); +} + +function record(value: unknown): Record | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record : undefined; +} diff --git a/src/application/ports/workflow_run_ports.ts b/src/application/ports/workflow_run_ports.ts index 2f56f1cd8..35f8f1f58 100644 --- a/src/application/ports/workflow_run_ports.ts +++ b/src/application/ports/workflow_run_ports.ts @@ -4,6 +4,21 @@ export interface PreviousWorkflowRunsQuery { currentRunId: number; /** Workflow file name accepted by GitHub to scope the queue query. */ workflowIdentifier?: string; + /** GitHub-provided executing workflow metadata; never an opt-out flag. */ + nativeCoordination?: NativeWorkflowCoordinationContext; +} + +export interface NativeWorkflowCoordinationContext { + readonly workflowRef: string; + readonly workflowRevision: string; + readonly jobId: string; + readonly eventName: string; + readonly ref?: string; + readonly pullRequestNumber?: number; +} + +export interface NativeWorkflowCoordinationQueryPort { + isCoordinated(query: PreviousWorkflowRunsQuery, context: WorkflowQueueRequestContext): Promise; } export interface WorkflowQueueRequestContext { @@ -34,6 +49,7 @@ export interface WorkflowProviderRetryObservation { } export interface WorkflowPollingObserverPort { + nativeCoordinationVerified?(): void; noActivePreviousRuns(): void; waitingForPreviousRuns(activeRunCount: number, delayMilliseconds: number): void; providerRetry?(observation: WorkflowProviderRetryObservation): void; diff --git a/src/application/usecases/workflow/__tests__/wait_for_previous_workflow_runs_use_case.test.ts b/src/application/usecases/workflow/__tests__/wait_for_previous_workflow_runs_use_case.test.ts index 1170158a9..04ce5eb46 100644 --- a/src/application/usecases/workflow/__tests__/wait_for_previous_workflow_runs_use_case.test.ts +++ b/src/application/usecases/workflow/__tests__/wait_for_previous_workflow_runs_use_case.test.ts @@ -29,6 +29,75 @@ function policy(overrides: Partial = {}): WorkflowPolling } describe('WaitForPreviousWorkflowRunsUseCase', () => { + afterEach(() => jest.restoreAllMocks()); + + it('uses the system clock and random source for bounded polling by default', async () => { + jest.spyOn(Date, 'now').mockReturnValue(1000); + const random = jest.spyOn(Math, 'random').mockReturnValue(0.5); + const predecessors = { countActivePreviousRuns: jest.fn().mockResolvedValueOnce(1).mockResolvedValue(0) }; + const delay = { wait: jest.fn().mockResolvedValue(undefined) }; + const observed = observer(); + + await new WaitForPreviousWorkflowRunsUseCase(predecessors, delay, observed).invoke(query); + + expect(predecessors.countActivePreviousRuns).toHaveBeenCalledWith(query, { deadlineAtMilliseconds: 5401000 }); + expect(delay.wait).toHaveBeenCalledWith(5000); + expect(random).toHaveBeenCalledTimes(1); + expect(observed.noActivePreviousRuns).toHaveBeenCalledTimes(1); + }); + + it('does not require a native-coordination observer to delegate safely', async () => { + const predecessors = { countActivePreviousRuns: jest.fn() }; + const delay = { wait: jest.fn() }; + await new WaitForPreviousWorkflowRunsUseCase(predecessors, delay, observer(), undefined, + { nowMilliseconds: () => 0 }, undefined, { isCoordinated: jest.fn().mockResolvedValue(true) }).invoke(query); + expect(predecessors.countActivePreviousRuns).not.toHaveBeenCalled(); + expect(delay.wait).not.toHaveBeenCalled(); + }); + + it('delegates to proven native coordination without enumerating or waiting for queued predecessors', async () => { + const predecessors = { countActivePreviousRuns: jest.fn().mockResolvedValue(1) }; + const native = { isCoordinated: jest.fn().mockResolvedValue(true) }; + const delay = { wait: jest.fn() }; + const observed = { ...observer(), nativeCoordinationVerified: jest.fn() }; + await new WaitForPreviousWorkflowRunsUseCase(predecessors, delay, observed, undefined, + { nowMilliseconds: () => 0 }, undefined, native).invoke(query); + expect(native.isCoordinated).toHaveBeenCalledWith(query, { deadlineAtMilliseconds: 5400000 }); + expect(predecessors.countActivePreviousRuns).not.toHaveBeenCalled(); + expect(delay.wait).not.toHaveBeenCalled(); + expect(observed.nativeCoordinationVerified).toHaveBeenCalledTimes(1); + expect(observed.noActivePreviousRuns).not.toHaveBeenCalled(); + }); + + it('keeps the predecessor fence when native proof is absent and inspects proof only once', async () => { + const predecessors = { countActivePreviousRuns: jest.fn().mockResolvedValueOnce(1).mockResolvedValue(0) }; + const native = { isCoordinated: jest.fn().mockResolvedValue(false) }; + const delay = { wait: jest.fn() }; + await new WaitForPreviousWorkflowRunsUseCase(predecessors, delay, observer(), undefined, + { nowMilliseconds: () => 0 }, { next: () => 0.5 }, native).invoke(query); + expect(predecessors.countActivePreviousRuns).toHaveBeenCalledTimes(2); + expect(delay.wait).toHaveBeenCalledWith(5000); + expect(native.isCoordinated).toHaveBeenCalledTimes(1); + }); + + it('propagates proof errors without treating the predecessor queue as empty', async () => { + const predecessors = { countActivePreviousRuns: jest.fn() }; + const native = { isCoordinated: jest.fn().mockRejectedValue(new Error('denied')) }; + await expect(new WaitForPreviousWorkflowRunsUseCase(predecessors, { wait: jest.fn() }, observer(), + undefined, { nowMilliseconds: () => 0 }, undefined, native).invoke(query)).rejects.toThrow('denied'); + expect(predecessors.countActivePreviousRuns).not.toHaveBeenCalled(); + }); + + it('includes native proof in the absolute queue deadline', async () => { + let now = 0; + const predecessors = { countActivePreviousRuns: jest.fn() }; + const native = { isCoordinated: jest.fn(async () => { now = 100; return true; }) }; + await expect(new WaitForPreviousWorkflowRunsUseCase(predecessors, { wait: jest.fn() }, observer(), + policy({ maximumQueueWaitMilliseconds: 100 }), { nowMilliseconds: () => now }, undefined, native) + .invoke(query)).rejects.toThrow('Timeout'); + expect(predecessors.countActivePreviousRuns).not.toHaveBeenCalled(); + }); + it('queries immediately and reports when no previous runs are active', async () => { const queryPort: PreviousWorkflowRunsQueryPort = { countActivePreviousRuns: jest.fn().mockResolvedValue(0), @@ -145,4 +214,28 @@ describe('WaitForPreviousWorkflowRunsUseCase', () => { ).invoke(query)).rejects.toThrow('Timeout waiting for previous runs to finish.'); expect(queryPort.countActivePreviousRuns).not.toHaveBeenCalled(); }); + + it('does not start native proof when the queue deadline has already expired', async () => { + const queryPort = { countActivePreviousRuns: jest.fn() }; + const native = { isCoordinated: jest.fn() }; + await expect(new WaitForPreviousWorkflowRunsUseCase(queryPort, { wait: jest.fn() }, observer(), + policy({ maximumQueueWaitMilliseconds: 100 }), + { nowMilliseconds: jest.fn().mockReturnValueOnce(0).mockReturnValue(100) }, undefined, native, + ).invoke(query)).rejects.toThrow('Timeout waiting for previous runs to finish.'); + expect(native.isCoordinated).not.toHaveBeenCalled(); + expect(queryPort.countActivePreviousRuns).not.toHaveBeenCalled(); + }); + + it.each([0, 1])('rejects a provider result arriving at the deadline, even with %i predecessors', async active => { + let now = 0; + const queryPort = { countActivePreviousRuns: jest.fn(async () => { now = 100; return active; }) }; + const delay = { wait: jest.fn() }; + const observed = observer(); + await expect(new WaitForPreviousWorkflowRunsUseCase(queryPort, delay, observed, + policy({ maximumQueueWaitMilliseconds: 100 }), { nowMilliseconds: () => now }, + ).invoke(query)).rejects.toThrow('Timeout waiting for previous runs to finish.'); + expect(observed.noActivePreviousRuns).not.toHaveBeenCalled(); + expect(observed.waitingForPreviousRuns).not.toHaveBeenCalled(); + expect(delay.wait).not.toHaveBeenCalled(); + }); }); diff --git a/src/application/usecases/workflow/wait_for_previous_workflow_runs_use_case.ts b/src/application/usecases/workflow/wait_for_previous_workflow_runs_use_case.ts index 309d99792..8676ff815 100644 --- a/src/application/usecases/workflow/wait_for_previous_workflow_runs_use_case.ts +++ b/src/application/usecases/workflow/wait_for_previous_workflow_runs_use_case.ts @@ -1,5 +1,6 @@ import type { PreviousWorkflowRunsQuery, + NativeWorkflowCoordinationQueryPort, PreviousWorkflowRunsQueryPort, WorkflowPollingDelayPort, WorkflowPollingObserverPort, @@ -27,12 +28,22 @@ export class WaitForPreviousWorkflowRunsUseCase implements ParamUseCase { const deadlineAtMilliseconds = this.clock.nowMilliseconds() + this.policy.maximumQueueWaitMilliseconds; let pollIndex = 0; + if (this.nativeCoordination && this.clock.nowMilliseconds() < deadlineAtMilliseconds) { + const coordinated = await this.nativeCoordination.isCoordinated(query, { deadlineAtMilliseconds }); + if (this.clock.nowMilliseconds() >= deadlineAtMilliseconds) throw queueTimeoutError(); + if (coordinated) { + this.observerPort.nativeCoordinationVerified?.(); + return; + } + } + while (true) { if (this.clock.nowMilliseconds() >= deadlineAtMilliseconds) { throw queueTimeoutError(); diff --git a/src/data/repository/workflow/__tests__/native_workflow_coordination_repository.test.ts b/src/data/repository/workflow/__tests__/native_workflow_coordination_repository.test.ts new file mode 100644 index 000000000..a003ac904 --- /dev/null +++ b/src/data/repository/workflow/__tests__/native_workflow_coordination_repository.test.ts @@ -0,0 +1,87 @@ +import { NativeWorkflowCoordinationRepository } from '../native_workflow_coordination_repository'; +import type { PreviousWorkflowRunsQuery } from '../../../../application/ports/workflow_run_ports'; + +const group = 'copilot-pr-${{ github.repository }}-${{ github.event.pull_request.number }}-analysis'; +const source = `jobs:\n review:\n steps: []\n concurrency:\n group: ${group}\n cancel-in-progress: true\n`; +const query: PreviousWorkflowRunsQuery = { + owner: 'org', repository: 'repo', currentRunId: 200, workflowIdentifier: 'custom.yml', + nativeCoordination: { + workflowRef: 'org/repo/.github/workflows/custom.yml@refs/pull/21/merge', + workflowRevision: 'a'.repeat(40), jobId: 'review', eventName: 'pull_request', pullRequestNumber: 21, + }, +}; +const context = { deadlineAtMilliseconds: 100000 }; +const content = (text: string) => ({ data: { type: 'file', encoding: 'base64', content: Buffer.from(text).toString('base64') } }); + +function setup() { + const getContent = jest.fn().mockResolvedValue(content(source)); + const delayPort = { wait: jest.fn().mockResolvedValue(undefined) }; + const repository = new NativeWorkflowCoordinationRepository({ rest: { repos: { getContent } } }, { + clock: { nowMilliseconds: () => 0 }, random: { next: () => 0.5 }, delayPort, + }); + return { repository, getContent, delayPort }; +} + +describe('NativeWorkflowCoordinationRepository', () => { + it('reads the executing workflow revision and current job rather than a branch or checkout', async () => { + const { repository, getContent } = setup(); + await expect(repository.isCoordinated(query, context)).resolves.toBe(true); + expect(getContent).toHaveBeenCalledWith({ owner: 'org', repo: 'repo', path: '.github/workflows/custom.yml', ref: 'a'.repeat(40) }); + }); + + it('does not perform a content read for legacy metadata', async () => { + const { repository, getContent } = setup(); + await expect(repository.isCoordinated({ ...query, nativeCoordination: undefined }, context)).resolves.toBe(false); + expect(getContent).not.toHaveBeenCalled(); + }); + + it('retains polling for a valid but nonmatching workflow, even with the usual filename', async () => { + const { repository, getContent } = setup(); + getContent.mockResolvedValue(content('jobs: { review: { steps: [] } }')); + await expect(repository.isCoordinated(query, context)).resolves.toBe(false); + }); + + it.each([null, [], { type: 'dir' }, { type: 'file', encoding: 'base64' }, + { type: 'file', encoding: 'utf8', content: source }, + { type: 'file', encoding: 'base64', content: 'A'.repeat(256 * 1024 * 2 + 1) }, + ])('fails closed for malformed or oversized provider content %#', async value => { + const { repository, getContent } = setup(); + getContent.mockResolvedValue({ data: value }); + await expect(repository.isCoordinated(query, context)).rejects.toThrow('content is unavailable'); + }); + + it('rejects decoded content over the bound', async () => { + const { repository, getContent } = setup(); + getContent.mockResolvedValue(content('a'.repeat(256 * 1024 + 1))); + await expect(repository.isCoordinated(query, context)).rejects.toThrow('inspection limit'); + }); + + it('rejects malformed base64 instead of silently discarding invalid characters', async () => { + const { repository, getContent } = setup(); + const invalid = content(source); + invalid.data.content += '!'; + getContent.mockResolvedValue(invalid); + await expect(repository.isCoordinated(query, context)).rejects.toThrow('encoding is invalid'); + }); + + it.each(['jobs: [', 'jobs: !!js/function function() {}'])('rejects invalid YAML or executable tags', async text => { + const { repository, getContent } = setup(); + getContent.mockResolvedValue(content(text)); + await expect(repository.isCoordinated(query, context)).rejects.toThrow(); + }); + + it('retries a transient proof read using the existing bounded retry policy', async () => { + const { repository, getContent, delayPort } = setup(); + getContent.mockRejectedValueOnce({ status: 500 }); + await expect(repository.isCoordinated(query, context)).resolves.toBe(true); + expect(getContent).toHaveBeenCalledTimes(2); + expect(delayPort.wait).toHaveBeenCalledTimes(1); + }); + + it('does not turn a permanent proof-read failure into permission to proceed', async () => { + const { repository, getContent, delayPort } = setup(); + getContent.mockRejectedValue({ status: 403 }); + await expect(repository.isCoordinated(query, context)).rejects.toEqual({ status: 403 }); + expect(delayPort.wait).not.toHaveBeenCalled(); + }); +}); diff --git a/src/data/repository/workflow/native_workflow_coordination_repository.ts b/src/data/repository/workflow/native_workflow_coordination_repository.ts new file mode 100644 index 000000000..e3085aa25 --- /dev/null +++ b/src/data/repository/workflow/native_workflow_coordination_repository.ts @@ -0,0 +1,48 @@ +import { JSON_SCHEMA, load } from 'js-yaml'; +import type { + NativeWorkflowCoordinationQueryPort, + PreviousWorkflowRunsQuery, + WorkflowQueueRequestContext, +} from '../../../application/ports/workflow_run_ports'; +import { + expectedNativeWorkflowGroup, + hasNativeWorkflowCoordination, +} from '../../../application/policies/native_workflow_coordination_policy'; +import type { GithubWorkflowDefinitionClient } from '../../../infrastructure/github/ports/github_workflow_provider_ports'; +import { withWorkflowRunsRetry, WORKFLOW_RUNS_RETRY_POLICY, type WorkflowRunsRetryPolicy } from './workflow_runs_retry'; + +const MAX_WORKFLOW_BYTES = 256 * 1024; +type RetryDependencies = Parameters[1]; + +/** Reads the workflow GitHub executed, never the possibly modified checkout. */ +export class NativeWorkflowCoordinationRepository implements NativeWorkflowCoordinationQueryPort { + constructor( + private readonly client: GithubWorkflowDefinitionClient, + private readonly retry: Omit & { policy?: WorkflowRunsRetryPolicy }, + ) {} + + async isCoordinated(query: PreviousWorkflowRunsQuery, context: WorkflowQueueRequestContext): Promise { + const group = expectedNativeWorkflowGroup(query); + const native = query.nativeCoordination; + if (!group || !native) return false; + + const response = await withWorkflowRunsRetry(() => this.client.rest.repos.getContent({ + owner: query.owner, + repo: query.repository, + path: `.github/workflows/${query.workflowIdentifier}`, + ref: native.workflowRevision, + }), { ...this.retry, policy: this.retry.policy ?? WORKFLOW_RUNS_RETRY_POLICY, + deadlineAtMilliseconds: context.deadlineAtMilliseconds }); + const file = response.data as { type?: string; encoding?: string; content?: string } | null; + if (!file || Array.isArray(file) || file.type !== 'file' || file.encoding !== 'base64' + || typeof file.content !== 'string' || file.content.length > MAX_WORKFLOW_BYTES * 2) { + throw new Error('Executing workflow content is unavailable.'); + } + const encoded = file.content.replace(/\s/gu, ''); + const bytes = Buffer.from(encoded, 'base64'); + if (bytes.toString('base64') !== encoded) throw new Error('Executing workflow encoding is invalid.'); + if (bytes.length > MAX_WORKFLOW_BYTES) throw new Error('Executing workflow exceeds the inspection limit.'); + const workflow: unknown = load(bytes.toString('utf8'), { schema: JSON_SCHEMA }); + return hasNativeWorkflowCoordination(workflow, native.jobId, group); + } +} diff --git a/src/infrastructure/composition/workflow_queue_composition_root.ts b/src/infrastructure/composition/workflow_queue_composition_root.ts index f1f1ad9d5..2baada013 100644 --- a/src/infrastructure/composition/workflow_queue_composition_root.ts +++ b/src/infrastructure/composition/workflow_queue_composition_root.ts @@ -4,23 +4,33 @@ import { TimerWorkflowPollingDelayAdapter } from '../time/timer_workflow_polling import { LoggerWorkflowPollingObserverAdapter } from '../logging/logger_workflow_polling_observer_adapter'; import { SystemWorkflowQueueClockAdapter } from '../time/system_workflow_queue_clock_adapter'; import { SystemWorkflowPollingRandomAdapter } from '../time/system_workflow_polling_random_adapter'; -import { createWorkflowRunsClient } from './github_workflow_client_factory'; +import { NativeWorkflowCoordinationRepository } from '../../data/repository/workflow/native_workflow_coordination_repository'; +import { getOctokitClient } from '../github/octokit_client_resolver'; +import type { GithubWorkflowDefinitionClient, GithubWorkflowRunsClient } from '../github/ports/github_workflow_provider_ports'; export function createWaitForPreviousWorkflowRunsUseCase(token: string): WaitForPreviousWorkflowRunsUseCase { - const client = createWorkflowRunsClient().getClient(token); + const client = getOctokitClient(token); const delayPort = new TimerWorkflowPollingDelayAdapter(); const observerPort = new LoggerWorkflowPollingObserverAdapter(); + const clock = new SystemWorkflowQueueClockAdapter(); + const random = new SystemWorkflowPollingRandomAdapter(); return new WaitForPreviousWorkflowRunsUseCase( new ActivePreviousWorkflowRunsRepository( client, delayPort, undefined, - new SystemWorkflowQueueClockAdapter(), - new SystemWorkflowPollingRandomAdapter(), + clock, + random, observerPort, ), delayPort, observerPort, + undefined, + clock, + random, + new NativeWorkflowCoordinationRepository(client, { + delayPort, clock, random, observer: observerPort, + }), ); } diff --git a/src/infrastructure/github/ports/github_workflow_provider_ports.ts b/src/infrastructure/github/ports/github_workflow_provider_ports.ts index 58183b549..d70689c67 100644 --- a/src/infrastructure/github/ports/github_workflow_provider_ports.ts +++ b/src/infrastructure/github/ports/github_workflow_provider_ports.ts @@ -6,6 +6,14 @@ export interface GithubWorkflowRunsParameters { status?: string; } +export interface GithubWorkflowDefinitionClient { + rest: { + repos: { + getContent(parameters: { owner: string; repo: string; path: string; ref: string }): Promise<{ data: unknown }>; + }; + }; +} + export interface GithubWorkflowRunsResponse { data: { workflow_runs: GithubWorkflowRun[] } | GithubWorkflowRun[]; } diff --git a/src/infrastructure/logging/__tests__/logger_workflow_polling_observer_adapter.test.ts b/src/infrastructure/logging/__tests__/logger_workflow_polling_observer_adapter.test.ts index f87737c2a..1608b8d7b 100644 --- a/src/infrastructure/logging/__tests__/logger_workflow_polling_observer_adapter.test.ts +++ b/src/infrastructure/logging/__tests__/logger_workflow_polling_observer_adapter.test.ts @@ -13,11 +13,23 @@ describe('LoggerWorkflowPollingObserverAdapter', () => { const observer = new LoggerWorkflowPollingObserverAdapter(); observer.noActivePreviousRuns(); + observer.nativeCoordinationVerified(); observer.waitingForPreviousRuns(2, 2000); expect(logDebugInfo).toHaveBeenNthCalledWith(1, '✅ No previous runs active. Continuing...'); + expect(logInfo).toHaveBeenCalledWith('Native workflow resource/lane coordination verified; continuing before setup.'); expect(logInfo).toHaveBeenCalledWith( '⏳ Found 2 previous run(s) still active. Waiting 2s...', ); }); + + it.each([ + { reason: 'transient' as const, attempt: 1, delayMilliseconds: 1000 }, + { reason: 'rate_limit' as const, attempt: 2, delayMilliseconds: 2000, resetEpochSeconds: 1700000000 }, + ])('reports bounded retry metadata for $reason', observation => { + new LoggerWorkflowPollingObserverAdapter().providerRetry(observation); + + expect(logDebugInfo).toHaveBeenCalledWith('GitHub workflow polling retry scheduled.', false, observation); + expect(logInfo).not.toHaveBeenCalled(); + }); }); diff --git a/src/infrastructure/logging/logger_workflow_polling_observer_adapter.ts b/src/infrastructure/logging/logger_workflow_polling_observer_adapter.ts index f3f31ff0a..2a028aee8 100644 --- a/src/infrastructure/logging/logger_workflow_polling_observer_adapter.ts +++ b/src/infrastructure/logging/logger_workflow_polling_observer_adapter.ts @@ -2,6 +2,10 @@ import type { WorkflowPollingObserverPort } from '../../application/ports/workfl import { logDebugInfo, logInfo } from '../../utils/logger'; export class LoggerWorkflowPollingObserverAdapter implements WorkflowPollingObserverPort { + nativeCoordinationVerified(): void { + logInfo('Native workflow resource/lane coordination verified; continuing before setup.'); + } + noActivePreviousRuns(): void { logDebugInfo('✅ No previous runs active. Continuing...'); }