From 1c190613e72a58f3613946bdd9f44a8287ea0f1b Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Fri, 8 May 2026 15:00:36 +0000 Subject: [PATCH 01/18] refactor(triggers): extract agent execution lifecycle --- .../shared/agent-execution-lifecycle.ts | 147 +++++++++ src/triggers/shared/agent-execution.ts | 155 +--------- .../shared/agent-execution-lifecycle.test.ts | 279 ++++++++++++++++++ 3 files changed, 440 insertions(+), 141 deletions(-) create mode 100644 src/triggers/shared/agent-execution-lifecycle.ts create mode 100644 tests/unit/triggers/shared/agent-execution-lifecycle.test.ts diff --git a/src/triggers/shared/agent-execution-lifecycle.ts b/src/triggers/shared/agent-execution-lifecycle.ts new file mode 100644 index 000000000..c27fb91bd --- /dev/null +++ b/src/triggers/shared/agent-execution-lifecycle.ts @@ -0,0 +1,147 @@ +import type { AgentResult, ProjectConfig } from '../../types/index.js'; +import { logger } from '../../utils/logging.js'; +import type { TriggerResult } from '../types.js'; +import type { AgentExecutionConfig, AgentExecutionContext } from './agent-execution-types.js'; +import { handleAgentResultArtifacts } from './agent-result-handler.js'; +import { checkBudgetExceeded } from './budget.js'; +import { + formatValidationErrors, + type ValidationResult, + validateIntegrations, +} from './integration-validation.js'; + +/** + * Run integration validation before agent execution and notify the source when + * validation fails. + */ +export async function validateAgentExecution( + result: TriggerResult, + context: Pick, +): Promise { + const validation = await validateIntegrations( + context.project.id, + context.agentType, + context.project, + ); + if (validation.valid) return true; + + await notifyValidationFailure( + result, + validation, + context.lifecycle, + context.executionConfig, + context.agentType, + context.project.id, + ); + return false; +} + +/** + * Check the budget before running an agent. + * Returns the remaining budget if not exceeded, or null to signal the caller + * should abort (budget exceeded and lifecycle notified). + */ +export async function checkPreRunBudget( + workItemId: string, + project: ProjectConfig, + lifecycle: AgentExecutionContext['lifecycle'], +): Promise<{ remainingBudgetUsd: number | undefined; abort: boolean }> { + const budgetCheck = await checkBudgetExceeded(workItemId, project); + if (budgetCheck?.exceeded) { + logger.warn('Budget exceeded, agent not started', { + workItemId, + currentCost: budgetCheck.currentCost, + budget: budgetCheck.budget, + }); + await lifecycle.handleBudgetExceeded(workItemId, budgetCheck.currentCost, budgetCheck.budget); + return { remainingBudgetUsd: undefined, abort: true }; + } + return { remainingBudgetUsd: budgetCheck?.remaining, abort: false }; +} + +/** + * Prepare the PM lifecycle state before running an agent. + */ +export async function prepareAgentLifecycle(context: AgentExecutionContext): Promise { + if (context.workItemId && !context.executionConfig.skipPrepareForAgent) { + await context.lifecycle.prepareForAgent(context.workItemId, context.lifecycleHooks); + } +} + +/** + * Run post-agent lifecycle steps: artifact handling, budget warning, cleanup, + * success/failure. + */ +export async function runPostAgentLifecycle( + context: AgentExecutionContext, + agentResult: AgentResult, +): Promise { + const workItemId = context.workItemId; + if (!workItemId) return; + + const { + skipPrepareForAgent = false, + skipHandleFailure = false, + handleSuccessOnlyForAgentType, + } = context.executionConfig; + + await handleAgentResultArtifacts(workItemId, context.agentType, agentResult, context.project); + + const postBudgetCheck = await checkBudgetExceeded(workItemId, context.project); + if (postBudgetCheck?.exceeded) { + await context.lifecycle.handleBudgetWarning( + workItemId, + postBudgetCheck.currentCost, + postBudgetCheck.budget, + ); + } + + if (!skipPrepareForAgent) { + await context.lifecycle.cleanupProcessing(workItemId); + } + + const shouldCallHandleSuccess = + agentResult.success && + (!handleSuccessOnlyForAgentType || context.agentType === handleSuccessOnlyForAgentType); + + if (shouldCallHandleSuccess) { + await context.lifecycle.handleSuccess( + workItemId, + context.lifecycleHooks, + agentResult.prUrl, + agentResult.progressCommentId, + ); + } else if (!agentResult.success && !skipHandleFailure) { + await context.lifecycle.handleFailure(workItemId, agentResult.error); + } +} + +/** + * Notify PM and GitHub when integration validation fails before the agent runs. + */ +async function notifyValidationFailure( + result: TriggerResult, + validation: ValidationResult, + lifecycle: AgentExecutionContext['lifecycle'], + executionConfig: AgentExecutionConfig, + agentType: string, + projectId: string, +): Promise { + const errorMessage = formatValidationErrors(validation); + logger.error('Integration validation failed', { + agentType, + projectId, + errors: validation.errors, + }); + + // Only notify via PM if PM validation passed (otherwise PM isn't configured) + const pmFailed = validation.errors.some((e) => e.category === 'pm'); + if (result.workItemId && !pmFailed) { + await lifecycle.handleFailure(result.workItemId, errorMessage); + } + + // Call onFailure callback (for GitHub PR updates) + if (executionConfig.onFailure) { + await executionConfig.onFailure(result, { success: false, output: '', error: errorMessage }); + } +} diff --git a/src/triggers/shared/agent-execution.ts b/src/triggers/shared/agent-execution.ts index 0f52b6fd9..eea2af6da 100644 --- a/src/triggers/shared/agent-execution.ts +++ b/src/triggers/shared/agent-execution.ts @@ -18,8 +18,13 @@ import { logger } from '../../utils/logging.js'; import { extractPRNumber } from '../../utils/prUrl.js'; import { parseRepoFullName } from '../../utils/repo.js'; import type { TriggerResult } from '../types.js'; +import { + checkPreRunBudget, + prepareAgentLifecycle, + runPostAgentLifecycle, + validateAgentExecution, +} from './agent-execution-lifecycle.js'; import type { AgentExecutionConfig, AgentExecutionContext } from './agent-execution-types.js'; -import { handleAgentResultArtifacts } from './agent-result-handler.js'; import { linkPRPostExecution, persistPreRunWorkItems, @@ -27,119 +32,11 @@ import { resolveWorkItemId, } from './agent-work-items.js'; import { isPipelineAtCapacity } from './backlog-check.js'; -import { checkBudgetExceeded } from './budget.js'; import { triggerDebugAnalysis } from './debug-runner.js'; import { shouldTriggerDebug } from './debug-trigger.js'; -import { - formatValidationErrors, - type ValidationResult, - validateIntegrations, -} from './integration-validation.js'; export type { AgentExecutionConfig } from './agent-execution-types.js'; -/** - * Check the budget before running an agent. - * Returns the remaining budget if not exceeded, or null to signal the caller - * should abort (budget exceeded and lifecycle notified). - */ -async function checkPreRunBudget( - workItemId: string, - project: ProjectConfig, - lifecycle: PMLifecycleManager, -): Promise<{ remainingBudgetUsd: number | undefined; abort: boolean }> { - const budgetCheck = await checkBudgetExceeded(workItemId, project); - if (budgetCheck?.exceeded) { - logger.warn('Budget exceeded, agent not started', { - workItemId, - currentCost: budgetCheck.currentCost, - budget: budgetCheck.budget, - }); - await lifecycle.handleBudgetExceeded(workItemId, budgetCheck.currentCost, budgetCheck.budget); - return { remainingBudgetUsd: undefined, abort: true }; - } - return { remainingBudgetUsd: budgetCheck?.remaining, abort: false }; -} - -/** - * Run post-agent lifecycle steps: artifact handling, budget warning, cleanup, success/failure. - */ -async function runPostAgentLifecycle( - workItemId: string, - agentType: string, - agentResult: AgentResult, - project: ProjectConfig, - lifecycle: PMLifecycleManager, - lifecycleHooks: LifecycleHooks, - executionConfig: AgentExecutionConfig, -): Promise { - const { - skipPrepareForAgent = false, - skipHandleFailure = false, - handleSuccessOnlyForAgentType, - } = executionConfig; - - await handleAgentResultArtifacts(workItemId, agentType, agentResult, project); - - const postBudgetCheck = await checkBudgetExceeded(workItemId, project); - if (postBudgetCheck?.exceeded) { - await lifecycle.handleBudgetWarning( - workItemId, - postBudgetCheck.currentCost, - postBudgetCheck.budget, - ); - } - - if (!skipPrepareForAgent) { - await lifecycle.cleanupProcessing(workItemId); - } - - const shouldCallHandleSuccess = - agentResult.success && - (!handleSuccessOnlyForAgentType || agentType === handleSuccessOnlyForAgentType); - - if (shouldCallHandleSuccess) { - await lifecycle.handleSuccess( - workItemId, - lifecycleHooks, - agentResult.prUrl, - agentResult.progressCommentId, - ); - } else if (!agentResult.success && !skipHandleFailure) { - await lifecycle.handleFailure(workItemId, agentResult.error); - } -} - -/** - * Notify PM and GitHub when integration validation fails before the agent runs. - */ -async function notifyValidationFailure( - result: TriggerResult, - validation: ValidationResult, - lifecycle: PMLifecycleManager, - executionConfig: AgentExecutionConfig, - agentType: string, - projectId: string, -): Promise { - const errorMessage = formatValidationErrors(validation); - logger.error('Integration validation failed', { - agentType, - projectId, - errors: validation.errors, - }); - - // Only notify via PM if PM validation passed (otherwise PM isn't configured) - const pmFailed = validation.errors.some((e) => e.category === 'pm'); - if (result.workItemId && !pmFailed) { - await lifecycle.handleFailure(result.workItemId, errorMessage); - } - - // Call onFailure callback (for GitHub PR updates) - if (executionConfig.onFailure) { - await executionConfig.onFailure(result, { success: false, output: '', error: errorMessage }); - } -} - /** * Dispatch a review agent after a successful implementation run, if the PR's * CI is green and no review has been dispatched yet. @@ -362,21 +259,7 @@ export async function runAgentExecutionPipeline( }); } - // Pre-flight integration validation - const validation = await validateIntegrations(project.id, agentType, project); - if (!validation.valid) { - await notifyValidationFailure( - result, - validation, - lifecycle, - executionConfig, - agentType, - project.id, - ); - return; - } - - const { skipPrepareForAgent = false, onSuccess, onFailure, logLabel = 'Agent' } = executionConfig; + const { onSuccess, onFailure, logLabel = 'Agent' } = executionConfig; // Re-resolve workItemId at run time. The trigger handler (e.g. PROpenedTrigger) // captures workItemId synchronously at webhook arrival, before any other @@ -412,6 +295,11 @@ export async function runAgentExecutionPipeline( agentInput, }; + // Pre-flight integration validation + if (!(await validateAgentExecution(result, executionContext))) { + return; + } + let remainingBudgetUsd: number | undefined; if (executionContext.workItemId) { const budgetResult = await checkPreRunBudget( @@ -425,12 +313,7 @@ export async function runAgentExecutionPipeline( await persistPreRunWorkItems(result, project, workItemId); - if (executionContext.workItemId && !skipPrepareForAgent) { - await executionContext.lifecycle.prepareForAgent( - executionContext.workItemId, - executionContext.lifecycleHooks, - ); - } + await prepareAgentLifecycle(executionContext); const agentResult = await runAgent(executionContext.agentType, { ...executionContext.agentInput, @@ -452,17 +335,7 @@ export async function runAgentExecutionPipeline( // Post agent summary to PM work item (cross-source: works for all trigger types) await postAgentSummaryToPM(agentType, agentResult, workItemId, project.id, result.prNumber); - if (workItemId) { - await runPostAgentLifecycle( - workItemId, - agentType, - agentResult, - project, - lifecycle, - lifecycleHooks, - executionConfig, - ); - } + await runPostAgentLifecycle(executionContext, agentResult); logger.info(`${logLabel} completed`, { agentType, diff --git a/tests/unit/triggers/shared/agent-execution-lifecycle.test.ts b/tests/unit/triggers/shared/agent-execution-lifecycle.test.ts new file mode 100644 index 000000000..f2e5d3c92 --- /dev/null +++ b/tests/unit/triggers/shared/agent-execution-lifecycle.test.ts @@ -0,0 +1,279 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AgentExecutionContext } from '../../../../src/triggers/shared/agent-execution-types.js'; + +const { + mockCheckBudgetExceeded, + mockHandleAgentResultArtifacts, + mockValidateIntegrations, + mockFormatValidationErrors, + mockLogger, +} = vi.hoisted(() => ({ + mockCheckBudgetExceeded: vi.fn(), + mockHandleAgentResultArtifacts: vi.fn(), + mockValidateIntegrations: vi.fn(), + mockFormatValidationErrors: vi.fn().mockReturnValue('formatted validation error'), + mockLogger: { + warn: vi.fn(), + error: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock('../../../../src/triggers/shared/budget.js', () => ({ + checkBudgetExceeded: mockCheckBudgetExceeded, +})); + +vi.mock('../../../../src/triggers/shared/agent-result-handler.js', () => ({ + handleAgentResultArtifacts: mockHandleAgentResultArtifacts, +})); + +vi.mock('../../../../src/triggers/shared/integration-validation.js', () => ({ + validateIntegrations: mockValidateIntegrations, + formatValidationErrors: mockFormatValidationErrors, +})); + +vi.mock('../../../../src/utils/logging.js', () => ({ + logger: mockLogger, +})); + +import { + checkPreRunBudget, + prepareAgentLifecycle, + runPostAgentLifecycle, + validateAgentExecution, +} from '../../../../src/triggers/shared/agent-execution-lifecycle.js'; + +function makeLifecycle() { + return { + prepareForAgent: vi.fn().mockResolvedValue(undefined), + handleBudgetExceeded: vi.fn().mockResolvedValue(undefined), + handleBudgetWarning: vi.fn().mockResolvedValue(undefined), + cleanupProcessing: vi.fn().mockResolvedValue(undefined), + handleSuccess: vi.fn().mockResolvedValue(undefined), + handleFailure: vi.fn().mockResolvedValue(undefined), + }; +} + +function makeContext(overrides: Partial = {}): AgentExecutionContext { + return { + result: { agentType: 'implementation', agentInput: {}, workItemId: 'card-1' }, + project: { id: 'project-1', pm: { type: 'trello' } } as AgentExecutionContext['project'], + config: {} as AgentExecutionContext['config'], + executionConfig: {}, + agentType: 'implementation', + logLabel: 'Agent', + lifecycle: makeLifecycle() as unknown as AgentExecutionContext['lifecycle'], + lifecycleHooks: {}, + workItemId: 'card-1', + agentInput: { workItemId: 'card-1' }, + ...overrides, + }; +} + +describe('agent execution lifecycle helper', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockValidateIntegrations.mockResolvedValue({ valid: true, errors: [] }); + mockCheckBudgetExceeded.mockResolvedValue(null); + mockHandleAgentResultArtifacts.mockResolvedValue(undefined); + mockFormatValidationErrors.mockReturnValue('formatted validation error'); + }); + + describe('validateAgentExecution', () => { + it('returns true when validation passes', async () => { + const context = makeContext(); + + await expect(validateAgentExecution(context.result, context)).resolves.toBe(true); + + expect(mockValidateIntegrations).toHaveBeenCalledWith( + 'project-1', + 'implementation', + context.project, + ); + expect(context.lifecycle.handleFailure).not.toHaveBeenCalled(); + }); + + it('formats, logs, notifies PM, and invokes onFailure for non-PM validation failures', async () => { + mockValidateIntegrations.mockResolvedValueOnce({ + valid: false, + errors: [{ category: 'scm', message: 'missing token' }], + }); + const onFailure = vi.fn().mockResolvedValue(undefined); + const context = makeContext({ executionConfig: { onFailure } }); + + await expect(validateAgentExecution(context.result, context)).resolves.toBe(false); + + expect(mockFormatValidationErrors).toHaveBeenCalledWith({ + valid: false, + errors: [{ category: 'scm', message: 'missing token' }], + }); + expect(mockLogger.error).toHaveBeenCalledWith('Integration validation failed', { + agentType: 'implementation', + projectId: 'project-1', + errors: [{ category: 'scm', message: 'missing token' }], + }); + expect(context.lifecycle.handleFailure).toHaveBeenCalledWith( + 'card-1', + 'formatted validation error', + ); + expect(onFailure).toHaveBeenCalledWith(context.result, { + success: false, + output: '', + error: 'formatted validation error', + }); + }); + + it('skips PM notification when PM validation failed but still invokes onFailure', async () => { + mockValidateIntegrations.mockResolvedValueOnce({ + valid: false, + errors: [{ category: 'pm', message: 'missing PM integration' }], + }); + const onFailure = vi.fn().mockResolvedValue(undefined); + const context = makeContext({ executionConfig: { onFailure } }); + + await expect(validateAgentExecution(context.result, context)).resolves.toBe(false); + + expect(context.lifecycle.handleFailure).not.toHaveBeenCalled(); + expect(onFailure).toHaveBeenCalledWith(context.result, { + success: false, + output: '', + error: 'formatted validation error', + }); + }); + }); + + describe('checkPreRunBudget', () => { + it('aborts and notifies lifecycle when budget is exceeded', async () => { + const lifecycle = makeLifecycle(); + mockCheckBudgetExceeded.mockResolvedValueOnce({ + exceeded: true, + currentCost: 10, + budget: 7, + remaining: 0, + }); + + await expect( + checkPreRunBudget( + 'card-1', + { id: 'project-1' } as AgentExecutionContext['project'], + lifecycle as unknown as AgentExecutionContext['lifecycle'], + ), + ).resolves.toEqual({ remainingBudgetUsd: undefined, abort: true }); + + expect(mockLogger.warn).toHaveBeenCalledWith('Budget exceeded, agent not started', { + workItemId: 'card-1', + currentCost: 10, + budget: 7, + }); + expect(lifecycle.handleBudgetExceeded).toHaveBeenCalledWith('card-1', 10, 7); + }); + + it('returns remaining budget when under budget', async () => { + mockCheckBudgetExceeded.mockResolvedValueOnce({ + exceeded: false, + currentCost: 2, + budget: 7, + remaining: 5, + }); + + await expect( + checkPreRunBudget( + 'card-1', + { id: 'project-1' } as AgentExecutionContext['project'], + makeLifecycle() as unknown as AgentExecutionContext['lifecycle'], + ), + ).resolves.toEqual({ remainingBudgetUsd: 5, abort: false }); + }); + }); + + describe('prepareAgentLifecycle', () => { + it('calls prepareForAgent unless skipPrepareForAgent is set', async () => { + const context = makeContext(); + + await prepareAgentLifecycle(context); + + expect(context.lifecycle.prepareForAgent).toHaveBeenCalledWith('card-1', {}); + }); + + it('does not call prepareForAgent when skipped', async () => { + const context = makeContext({ executionConfig: { skipPrepareForAgent: true } }); + + await prepareAgentLifecycle(context); + + expect(context.lifecycle.prepareForAgent).not.toHaveBeenCalled(); + }); + }); + + describe('runPostAgentLifecycle', () => { + it('runs artifacts, budget warning, cleanup, and success in order', async () => { + mockCheckBudgetExceeded.mockResolvedValueOnce({ + exceeded: true, + currentCost: 8, + budget: 7, + remaining: 0, + }); + const lifecycle = makeLifecycle(); + const context = makeContext({ + lifecycle: lifecycle as unknown as AgentExecutionContext['lifecycle'], + }); + + await runPostAgentLifecycle(context, { + success: true, + output: '', + prUrl: 'https://github.com/acme/app/pull/1', + progressCommentId: 'progress-1', + }); + + expect(mockHandleAgentResultArtifacts).toHaveBeenCalledWith( + 'card-1', + 'implementation', + expect.objectContaining({ success: true }), + context.project, + ); + expect(lifecycle.handleBudgetWarning).toHaveBeenCalledWith('card-1', 8, 7); + expect(lifecycle.cleanupProcessing).toHaveBeenCalledWith('card-1'); + expect(lifecycle.handleSuccess).toHaveBeenCalledWith( + 'card-1', + {}, + 'https://github.com/acme/app/pull/1', + 'progress-1', + ); + + expect(mockHandleAgentResultArtifacts.mock.invocationCallOrder[0]).toBeLessThan( + lifecycle.handleBudgetWarning.mock.invocationCallOrder[0], + ); + expect(lifecycle.handleBudgetWarning.mock.invocationCallOrder[0]).toBeLessThan( + lifecycle.cleanupProcessing.mock.invocationCallOrder[0], + ); + expect(lifecycle.cleanupProcessing.mock.invocationCallOrder[0]).toBeLessThan( + lifecycle.handleSuccess.mock.invocationCallOrder[0], + ); + }); + + it('honors skipPrepareForAgent, skipHandleFailure, and handleSuccessOnlyForAgentType', async () => { + const context = makeContext({ + agentType: 'review', + executionConfig: { + skipPrepareForAgent: true, + skipHandleFailure: true, + handleSuccessOnlyForAgentType: 'implementation', + }, + }); + + await runPostAgentLifecycle(context, { success: false, output: '', error: 'failed' }); + + expect(context.lifecycle.cleanupProcessing).not.toHaveBeenCalled(); + expect(context.lifecycle.handleSuccess).not.toHaveBeenCalled(); + expect(context.lifecycle.handleFailure).not.toHaveBeenCalled(); + }); + + it('calls handleFailure for failed agents when failure handling is enabled', async () => { + const context = makeContext(); + + await runPostAgentLifecycle(context, { success: false, output: '', error: 'failed' }); + + expect(context.lifecycle.handleFailure).toHaveBeenCalledWith('card-1', 'failed'); + }); + }); +}); From fc6ca422e64c27e8d697aa5df364fad818a4329d Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Fri, 8 May 2026 20:46:08 +0000 Subject: [PATCH 02/18] feat(triggers): centralize trigger enablement checks --- src/triggers/shared/gates.ts | 13 +- src/triggers/shared/trigger-check.ts | 77 ++++++++--- tests/helpers/sharedMocks.ts | 28 +++- tests/unit/triggers/shared/gates.test.ts | 27 +++- .../triggers/shared/trigger-check.test.ts | 128 +++++++++++++++--- 5 files changed, 218 insertions(+), 55 deletions(-) diff --git a/src/triggers/shared/gates.ts b/src/triggers/shared/gates.ts index 2fd9880fd..7f2e5e5f3 100644 --- a/src/triggers/shared/gates.ts +++ b/src/triggers/shared/gates.ts @@ -3,7 +3,7 @@ import { isCascadeBot } from '../../github/personas.js'; import type { ProjectConfig, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; import { skip } from './skip.js'; -import { checkTriggerEnabled } from './trigger-check.js'; +import { checkTriggerEnablement } from './trigger-check.js'; /** * Composable self-skip gates for trigger handlers. @@ -32,8 +32,8 @@ import { checkTriggerEnabled } from './trigger-check.js'; /** * Async gate: is the trigger enabled in this project's `agent_trigger_configs`? * - * Wraps `checkTriggerEnabled` and converts a `false` result into a - * structured skip. Eliminates the repeated `if (!await checkTriggerEnabled(...)) return null;` + * Wraps the centralized trigger enablement helper and returns its structured + * skip. Eliminates the repeated `if (!await checkTriggerEnabled(...)) return null;` * pattern across every handler. */ export async function gateTriggerEnabled( @@ -42,11 +42,8 @@ export async function gateTriggerEnabled( triggerEvent: string, handlerName: string, ): Promise { - const enabled = await checkTriggerEnabled(projectId, agentType, triggerEvent, handlerName); - if (!enabled) { - return skip(handlerName, `${agentType} trigger is disabled for this project`); - } - return null; + const result = await checkTriggerEnablement(projectId, agentType, triggerEvent, handlerName); + return result.skipResult; } /** diff --git a/src/triggers/shared/trigger-check.ts b/src/triggers/shared/trigger-check.ts index 38a293916..25f77857b 100644 --- a/src/triggers/shared/trigger-check.ts +++ b/src/triggers/shared/trigger-check.ts @@ -5,8 +5,59 @@ * with consistent logging. */ +import type { TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; -import { getResolvedTriggerConfig, isTriggerEnabled } from '../config-resolver.js'; +import { getResolvedTriggerConfig } from '../config-resolver.js'; +import { skip } from './skip.js'; + +export interface TriggerEnablementCheck { + enabled: boolean; + parameters: Record; + skipResult: TriggerResult | null; +} + +const DISABLED_TRIGGER_LOG_MESSAGE = 'Trigger disabled by config, skipping'; + +function logDisabledTrigger( + projectId: string, + agentType: string, + triggerEvent: string, + handlerName: string, +) { + logger.info(DISABLED_TRIGGER_LOG_MESSAGE, { + handler: handlerName, + agentType, + triggerEvent, + projectId, + }); +} + +/** + * Resolve trigger enablement, merged parameters, and structured disabled-skip + * output in one config lookup. This is the canonical path for handler gates. + */ +export async function checkTriggerEnablement( + projectId: string, + agentType: string, + triggerEvent: string, + handlerName: string, +): Promise { + const config = await getResolvedTriggerConfig(projectId, agentType, triggerEvent); + if (!config?.enabled) { + logDisabledTrigger(projectId, agentType, triggerEvent, handlerName); + return { + enabled: false, + parameters: config?.parameters ?? {}, + skipResult: skip(handlerName, `${agentType} trigger is disabled for this project`), + }; + } + + return { + enabled: true, + parameters: config.parameters, + skipResult: null, + }; +} /** * Check whether a trigger is enabled for a project/agent/event combination. @@ -18,16 +69,8 @@ export async function checkTriggerEnabled( triggerEvent: string, handlerName: string, ): Promise { - const enabled = await isTriggerEnabled(projectId, agentType, triggerEvent); - if (!enabled) { - logger.info('Trigger disabled by config, skipping', { - handler: handlerName, - agentType, - triggerEvent, - projectId, - }); - } - return enabled; + const result = await checkTriggerEnablement(projectId, agentType, triggerEvent, handlerName); + return result.enabled; } /** @@ -40,15 +83,9 @@ export async function checkTriggerEnabledWithParams( triggerEvent: string, handlerName: string, ): Promise<{ enabled: boolean; parameters: Record }> { - const config = await getResolvedTriggerConfig(projectId, agentType, triggerEvent); - if (!config?.enabled) { - logger.info('Trigger disabled by config, skipping', { - handler: handlerName, - agentType, - triggerEvent, - projectId, - }); + const result = await checkTriggerEnablement(projectId, agentType, triggerEvent, handlerName); + if (!result.enabled) { return { enabled: false, parameters: {} }; } - return { enabled: true, parameters: config.parameters }; + return { enabled: true, parameters: result.parameters }; } diff --git a/tests/helpers/sharedMocks.ts b/tests/helpers/sharedMocks.ts index 486a03c15..a26afd769 100644 --- a/tests/helpers/sharedMocks.ts +++ b/tests/helpers/sharedMocks.ts @@ -133,8 +133,34 @@ export const mockGitHubClientModule = { * vi.mock('../../src/triggers/shared/trigger-check.js', () => mockTriggerCheckModule); * ``` */ +const mockCheckTriggerEnabled = vi.fn().mockResolvedValue(true); + export const mockTriggerCheckModule = { - checkTriggerEnabled: vi.fn().mockResolvedValue(true), + checkTriggerEnablement: vi.fn( + async (projectId: string, agentType: string, triggerEvent: string, handlerName: string) => { + const enabled = await mockCheckTriggerEnabled( + projectId, + agentType, + triggerEvent, + handlerName, + ); + return { + enabled, + parameters: {}, + skipResult: enabled + ? null + : { + agentType: null, + agentInput: {}, + skipReason: { + handler: handlerName, + message: `${agentType} trigger is disabled for this project`, + }, + }, + }; + }, + ), + checkTriggerEnabled: mockCheckTriggerEnabled, checkTriggerEnabledWithParams: vi.fn().mockResolvedValue({ enabled: true, parameters: {} }), }; diff --git a/tests/unit/triggers/shared/gates.test.ts b/tests/unit/triggers/shared/gates.test.ts index 669d1a4a4..5a37b37b0 100644 --- a/tests/unit/triggers/shared/gates.test.ts +++ b/tests/unit/triggers/shared/gates.test.ts @@ -11,7 +11,7 @@ import { gateTriggerEnabled, requirePersonaIdentities, } from '../../../../src/triggers/shared/gates.js'; -import { checkTriggerEnabled } from '../../../../src/triggers/shared/trigger-check.js'; +import { checkTriggerEnablement } from '../../../../src/triggers/shared/trigger-check.js'; import type { ProjectConfig } from '../../../../src/types/index.js'; import { createMockProject } from '../../../helpers/factories.js'; @@ -24,11 +24,15 @@ const mockPersonas: PersonaIdentities = { describe('gateTriggerEnabled', () => { beforeEach(() => { - vi.mocked(checkTriggerEnabled).mockReset(); + vi.mocked(checkTriggerEnablement).mockReset(); }); - it('returns null when checkTriggerEnabled resolves true', async () => { - vi.mocked(checkTriggerEnabled).mockResolvedValue(true); + it('returns null when checkTriggerEnablement has no skip result', async () => { + vi.mocked(checkTriggerEnablement).mockResolvedValue({ + enabled: true, + parameters: {}, + skipResult: null, + }); const result = await gateTriggerEnabled( 'proj', 'respond-to-ci', @@ -38,8 +42,19 @@ describe('gateTriggerEnabled', () => { expect(result).toBeNull(); }); - it('returns a structured skip when checkTriggerEnabled resolves false', async () => { - vi.mocked(checkTriggerEnabled).mockResolvedValue(false); + it('returns the centralized structured skip when checkTriggerEnablement resolves disabled', async () => { + vi.mocked(checkTriggerEnablement).mockResolvedValue({ + enabled: false, + parameters: {}, + skipResult: { + agentType: null, + agentInput: {}, + skipReason: { + handler: 'check-suite-failure', + message: 'respond-to-ci trigger is disabled for this project', + }, + }, + }); const result = await gateTriggerEnabled( 'proj', 'respond-to-ci', diff --git a/tests/unit/triggers/shared/trigger-check.test.ts b/tests/unit/triggers/shared/trigger-check.test.ts index 7ab8e9757..11e213908 100644 --- a/tests/unit/triggers/shared/trigger-check.test.ts +++ b/tests/unit/triggers/shared/trigger-check.test.ts @@ -1,7 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const { mockIsTriggerEnabled, mockGetResolvedTriggerConfig, mockLogger } = vi.hoisted(() => ({ - mockIsTriggerEnabled: vi.fn(), +const { mockGetResolvedTriggerConfig, mockLogger } = vi.hoisted(() => ({ mockGetResolvedTriggerConfig: vi.fn(), mockLogger: { info: vi.fn(), @@ -12,7 +11,6 @@ const { mockIsTriggerEnabled, mockGetResolvedTriggerConfig, mockLogger } = vi.ho })); vi.mock('../../../../src/triggers/config-resolver.js', () => ({ - isTriggerEnabled: mockIsTriggerEnabled, getResolvedTriggerConfig: mockGetResolvedTriggerConfig, })); @@ -23,6 +21,7 @@ vi.mock('../../../../src/utils/logging.js', () => ({ import { checkTriggerEnabled, checkTriggerEnabledWithParams, + checkTriggerEnablement, } from '../../../../src/triggers/shared/trigger-check.js'; const PROJECT_ID = 'project-1'; @@ -30,31 +29,78 @@ const AGENT_TYPE = 'implementation'; const TRIGGER_EVENT = 'pm:status-changed'; const HANDLER_NAME = 'test-handler'; -describe('checkTriggerEnabled', () => { +describe('checkTriggerEnablement', () => { beforeEach(() => { vi.resetAllMocks(); }); - it('returns true when trigger is enabled', async () => { - mockIsTriggerEnabled.mockResolvedValue(true); + it('returns enabled=true, merged parameters, and no skip when trigger is enabled', async () => { + mockGetResolvedTriggerConfig.mockResolvedValue({ + event: TRIGGER_EVENT, + enabled: true, + parameters: { authorMode: 'own' }, + isCustomized: false, + }); - const result = await checkTriggerEnabled(PROJECT_ID, AGENT_TYPE, TRIGGER_EVENT, HANDLER_NAME); + const result = await checkTriggerEnablement( + PROJECT_ID, + AGENT_TYPE, + TRIGGER_EVENT, + HANDLER_NAME, + ); - expect(result).toBe(true); + expect(result).toEqual({ + enabled: true, + parameters: { authorMode: 'own' }, + skipResult: null, + }); }); - it('returns false when trigger is disabled', async () => { - mockIsTriggerEnabled.mockResolvedValue(false); + it('returns enabled=false, default parameters, and structured skip when config is disabled', async () => { + mockGetResolvedTriggerConfig.mockResolvedValue({ + event: TRIGGER_EVENT, + enabled: false, + parameters: { authorMode: 'own' }, + isCustomized: true, + }); - const result = await checkTriggerEnabled(PROJECT_ID, AGENT_TYPE, TRIGGER_EVENT, HANDLER_NAME); + const result = await checkTriggerEnablement( + PROJECT_ID, + AGENT_TYPE, + TRIGGER_EVENT, + HANDLER_NAME, + ); - expect(result).toBe(false); + expect(result.enabled).toBe(false); + expect(result.parameters).toEqual({ authorMode: 'own' }); + expect(result.skipResult?.agentType).toBeNull(); + expect(result.skipResult?.skipReason).toEqual({ + handler: HANDLER_NAME, + message: `${AGENT_TYPE} trigger is disabled for this project`, + }); + }); + + it('returns enabled=false, empty parameters, and structured skip when config is missing', async () => { + mockGetResolvedTriggerConfig.mockResolvedValue(null); + + const result = await checkTriggerEnablement( + PROJECT_ID, + AGENT_TYPE, + TRIGGER_EVENT, + HANDLER_NAME, + ); + + expect(result.enabled).toBe(false); + expect(result.parameters).toEqual({}); + expect(result.skipResult?.skipReason?.message).toBe( + `${AGENT_TYPE} trigger is disabled for this project`, + ); }); it('logs skip message when trigger is disabled', async () => { - mockIsTriggerEnabled.mockResolvedValue(false); + mockGetResolvedTriggerConfig.mockResolvedValue(null); - await checkTriggerEnabled(PROJECT_ID, AGENT_TYPE, TRIGGER_EVENT, HANDLER_NAME); + await checkTriggerEnablement(PROJECT_ID, AGENT_TYPE, TRIGGER_EVENT, HANDLER_NAME); expect(mockLogger.info).toHaveBeenCalledWith('Trigger disabled by config, skipping', { handler: HANDLER_NAME, @@ -65,19 +111,61 @@ describe('checkTriggerEnabled', () => { }); it('does not log when trigger is enabled', async () => { - mockIsTriggerEnabled.mockResolvedValue(true); + mockGetResolvedTriggerConfig.mockResolvedValue({ + event: TRIGGER_EVENT, + enabled: true, + parameters: {}, + isCustomized: false, + }); - await checkTriggerEnabled(PROJECT_ID, AGENT_TYPE, TRIGGER_EVENT, HANDLER_NAME); + await checkTriggerEnablement(PROJECT_ID, AGENT_TYPE, TRIGGER_EVENT, HANDLER_NAME); expect(mockLogger.info).not.toHaveBeenCalled(); }); - it('passes all arguments to isTriggerEnabled', async () => { - mockIsTriggerEnabled.mockResolvedValue(true); + it('performs one resolved config lookup', async () => { + mockGetResolvedTriggerConfig.mockResolvedValue({ + event: TRIGGER_EVENT, + enabled: true, + parameters: {}, + isCustomized: false, + }); + + await checkTriggerEnablement(PROJECT_ID, AGENT_TYPE, TRIGGER_EVENT, HANDLER_NAME); + + expect(mockGetResolvedTriggerConfig).toHaveBeenCalledTimes(1); + expect(mockGetResolvedTriggerConfig).toHaveBeenCalledWith( + PROJECT_ID, + AGENT_TYPE, + TRIGGER_EVENT, + ); + }); +}); + +describe('checkTriggerEnabled', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('returns true when trigger is enabled', async () => { + mockGetResolvedTriggerConfig.mockResolvedValue({ + event: TRIGGER_EVENT, + enabled: true, + parameters: {}, + isCustomized: false, + }); + + const result = await checkTriggerEnabled(PROJECT_ID, AGENT_TYPE, TRIGGER_EVENT, HANDLER_NAME); + + expect(result).toBe(true); + }); + + it('returns false when trigger is disabled', async () => { + mockGetResolvedTriggerConfig.mockResolvedValue(null); - await checkTriggerEnabled(PROJECT_ID, AGENT_TYPE, TRIGGER_EVENT, HANDLER_NAME); + const result = await checkTriggerEnabled(PROJECT_ID, AGENT_TYPE, TRIGGER_EVENT, HANDLER_NAME); - expect(mockIsTriggerEnabled).toHaveBeenCalledWith(PROJECT_ID, AGENT_TYPE, TRIGGER_EVENT); + expect(result).toBe(false); }); }); From 4d4a3737daf5e42ac7343297ca48b796a77b2d7c Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Sat, 9 May 2026 08:00:30 +0000 Subject: [PATCH 03/18] test(triggers): expand agent-execution-lifecycle coverage for uncovered branches Add four tests that exercise code paths not covered by the original 8: - validateAgentExecutionLifecycle: skips PM notification when workItemId is absent from result (exercises the `result.workItemId` guard) - prepareAgentExecutionLifecycle: skips prepareForAgent when workItemId is absent from context (exercises the `context.workItemId` guard) - runPostAgentExecutionLifecycle: skips handleBudgetWarning when the post-run budget check returns null (no budget field configured) - runPostAgentExecutionLifecycle: calls handleFailure when the agent fails and skipHandleFailure is not set Co-Authored-By: Claude Sonnet 4.6 --- .../shared/agent-execution-lifecycle.test.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/unit/triggers/shared/agent-execution-lifecycle.test.ts b/tests/unit/triggers/shared/agent-execution-lifecycle.test.ts index f2dd7278b..f646b44bf 100644 --- a/tests/unit/triggers/shared/agent-execution-lifecycle.test.ts +++ b/tests/unit/triggers/shared/agent-execution-lifecycle.test.ts @@ -280,4 +280,67 @@ describe('agent execution lifecycle helper', () => { expect(lifecycle.handleSuccess).not.toHaveBeenCalled(); expect(lifecycle.handleFailure).not.toHaveBeenCalled(); }); + + it('calls handleFailure when agent fails and skipHandleFailure is not set', async () => { + const lifecycle = createLifecycle(); + + await runPostAgentExecutionLifecycle( + 'card-1', + 'implementation', + { success: false, output: '', error: 'agent error' }, + PROJECT, + lifecycle, + {}, + {}, + ); + + expect(lifecycle.handleFailure).toHaveBeenCalledWith('card-1', 'agent error'); + expect(lifecycle.handleSuccess).not.toHaveBeenCalled(); + }); + + it('skips handleBudgetWarning when post-run budget check returns null', async () => { + const lifecycle = createLifecycle(); + // default mock: mockCheckBudgetExceeded returns null (no budget configured) + + await runPostAgentExecutionLifecycle( + 'card-1', + 'implementation', + { success: true, output: '' }, + PROJECT, + lifecycle, + {}, + {}, + ); + + expect(lifecycle.handleBudgetWarning).not.toHaveBeenCalled(); + expect(lifecycle.handleSuccess).toHaveBeenCalledWith('card-1', {}, undefined, undefined); + }); + + it('skips PM notification when workItemId is absent from result', async () => { + const lifecycle = createLifecycle(); + const onFailure = vi.fn().mockResolvedValue(undefined); + mockValidateIntegrations.mockResolvedValueOnce({ + valid: false, + errors: [{ category: 'scm' as const, message: 'GitHub token missing' }], + }); + + await validateAgentExecutionLifecycle({ + result: { agentType: 'implementation', agentInput: {} }, // no workItemId + project: PROJECT, + executionConfig: { onFailure }, + agentType: 'implementation', + lifecycle, + }); + + expect(lifecycle.handleFailure).not.toHaveBeenCalled(); + expect(onFailure).toHaveBeenCalled(); + }); + + it('skips prepareForAgent when workItemId is absent from context', async () => { + const lifecycle = createLifecycle(); + + await prepareAgentExecutionLifecycle(createContext(lifecycle, { workItemId: undefined })); + + expect(lifecycle.prepareForAgent).not.toHaveBeenCalled(); + }); }); From b5d28cec1d2f16b9e7e070b3942563bc9c146129 Mon Sep 17 00:00:00 2001 From: aaight Date: Sat, 9 May 2026 11:10:03 +0200 Subject: [PATCH 04/18] docs: refresh active documentation (#1277) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: refresh active documentation * docs: remove stale architecture diagram embed from README Replace the embedded docs/architecture.jpg (stale — missing Linear/Sentry nodes) with a prose note pointing readers to the D2 source and the d2 CLI render command, so the README no longer surfaces a diagram that contradicts the updated docs/architecture.d2. Co-Authored-By: Claude Sonnet 4.6 * docs: remove stale WebhookStep statement from Post-spec-011 table The Post-spec-011 integration guide row said "The legacy WebhookStep remains … migration into the manifest path is follow-up scope." Spec 012 completed that migration (WebhookStep deleted, every PM wizard step now renders via the manifest path), making the statement both false and internally contradictory with the Post-spec-012 section immediately below it. Update the row to reflect that WebhookStep was temporarily retained during spec 011 and fully migrated in spec 012. Co-Authored-By: Claude Sonnet 4.6 * docs: fix stale TriggerResult and worker env contract docs - Remove `waitForChecks?: boolean` from the TriggerResult code block in 03-trigger-system.md; the field no longer exists in src/types/index.ts - Move `mergeabilityRecheckAttempt` mention from the JOB_TYPE row to the JOB_DATA row in 01-services.md; the router serializes it into the GitHub job object inside JOB_DATA, not as part of the job type string Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Cascade Bot Co-authored-by: Claude Sonnet 4.6 --- CLAUDE.md | 2 +- README.md | 11 ++- docs/ARCHITECTURE.md | 2 +- docs/architecture.d2 | 12 ++- docs/architecture/01-services.md | 4 +- docs/architecture/02-webhook-pipeline.md | 7 +- docs/architecture/03-trigger-system.md | 12 ++- docs/architecture/04-agent-system.md | 2 +- docs/architecture/06-integration-layer.md | 39 +++++----- docs/architecture/07-gadgets.md | 8 +- docs/architecture/08-config-credentials.md | 8 +- docs/architecture/09-database.md | 2 +- docs/architecture/10-resilience.md | 18 ++++- docs/cascade-directory.md | 21 ++++++ docs/getting-started.md | 18 ++++- src/gadgets/README.md | 2 + src/integrations/README.md | 16 ++-- tests/README.md | 2 + tests/unit/architecture-docs.test.ts | 87 +++++++++++++++++++++- 19 files changed, 217 insertions(+), 56 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5592c11a0..48e25e73e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,7 +107,7 @@ cascade projects credentials-set --key GITHUB_TOKEN_REVIEWER --value ghp_.. ## Agent triggers Trigger format is category-prefixed: `{category}:{event}` -(e.g. `pm:status-changed`, `scm:check-suite-success`, `alerting:issue-created`). +(e.g. `pm:status-changed`, `scm:check-suite-success`, `alerting:issue-alert`). Configs live in the `agent_trigger_configs` table. Manage via: diff --git a/README.md b/README.md index 252c22e1d..4d7e175da 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ docker compose exec dashboard node dist/tools/create-admin-user.mjs \ --email admin@example.com --password changeme --name "Admin" ``` -Open **http://localhost:3001** and log in with your admin credentials. +Open **http://localhost:3001** and log in with your admin credentials. The router listens on **http://localhost:3000** for provider webhooks. For the full setup walkthrough — projects, credentials, webhooks, and triggers — see **[Getting Started](./docs/getting-started.md)**. @@ -51,9 +51,8 @@ For the full setup walkthrough — projects, credentials, webhooks, and triggers ## 🏗️ Architecture -

- CASCADE architecture diagram -

+> The architecture diagram source lives at [`docs/architecture.d2`](./docs/architecture.d2). +> Render it locally with the [D2 CLI](https://d2lang.com/): `d2 docs/architecture.d2 docs/architecture.svg`. Cascade runs as three independent services: @@ -152,7 +151,7 @@ All project-level credentials (GitHub tokens, PM keys, LLM API keys) are stored **Dual-persona GitHub model** — Cascade uses two separate GitHub bot accounts per project (implementer and reviewer) to prevent feedback loops. The implementer writes code and creates PRs; the reviewer reviews and approves them. -**Trigger system** — Events from Trello, JIRA, Linear, and GitHub webhooks are matched against registered `TriggerHandler` instances. Triggers are configured per-project in the database. +**Trigger system** — Events from Trello, JIRA, Linear, GitHub, and Sentry webhooks are matched against registered `TriggerHandler` instances. Triggers are configured per-project in the database. Event names are category-prefixed, for example `pm:status-changed`, `scm:check-suite-success`, and `alerting:issue-alert`. **Agent engines** — Agents run through a shared execution lifecycle with a pluggable engine registry. Default engine is `claude-code` (Anthropic Claude Code SDK). Alternatives: `llmist` (supports OpenRouter, Anthropic, OpenAI), `codex` (OpenAI Codex CLI), `opencode` (OpenCode server). @@ -160,7 +159,7 @@ All project-level credentials (GitHub tokens, PM keys, LLM API keys) are stored **`.cascade/` directory** — Each target repository can include a `.cascade/` directory with hooks that control how the agent sets up the project, lints after edits, and runs tests. See **[`.cascade/` Directory Guide](./docs/cascade-directory.md)**. -**Observable subprocesses** — `cascade-tools` streams child stdout/stderr live to the parent's stderr so LLM-driven agents can see progress as it happens, emits 30-second heartbeats during silent stretches, and enforces both idle-silence and wall-clock timeouts with SIGTERM→SIGKILL escalation across the full process tree. See [spec 013](./docs/specs/013-subprocess-output-streaming.md). +**Observable subprocesses** — `cascade-tools` streams child stdout/stderr live to the parent's stderr so LLM-driven agents can see progress as it happens, emits 30-second heartbeats during silent stretches, and enforces both idle-silence and wall-clock timeouts with SIGTERM→SIGKILL escalation across the full process tree. See [spec 013](./docs/specs/013-subprocess-output-streaming.md.done). For deeper documentation on all of these topics, see [CLAUDE.md](./CLAUDE.md). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ddf52cd89..c52075319 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -100,7 +100,7 @@ sequenceDiagram **YAML-based agent definitions** — Agents are defined declaratively in YAML files specifying identity, capabilities, triggers, prompts, and lifecycle hooks. Definitions resolve via three tiers: in-memory cache, database, then YAML files on disk. -**AsyncLocalStorage credential scoping** — Provider clients (GitHub, Trello, JIRA) use Node.js `AsyncLocalStorage` to scope credentials per-request, preventing cross-request credential leakage. +**AsyncLocalStorage credential scoping** — Provider clients (GitHub, Trello, JIRA, Linear, and PM dispatch scopes) use Node.js `AsyncLocalStorage` to scope credentials and active PM provider context per request, preventing cross-request credential leakage. ## Directory Map diff --git a/docs/architecture.d2 b/docs/architecture.d2 index e426e2c9c..248e557fd 100644 --- a/docs/architecture.d2 +++ b/docs/architecture.d2 @@ -15,12 +15,21 @@ SCM: { PM: { trello jira + linear style: { shadow: true fill: Orange } } +ALERTING: { + sentry + style: { + shadow: true + fill: Red + } +} + CASCADE: { router api @@ -68,8 +77,9 @@ client.cli <-> CASCADE.api SCM -> CASCADE.router: webhook triggers PM -> CASCADE.router: webhook triggers +ALERTING -> CASCADE.router: webhook triggers CASCADE.worker manager -> PM: updates, comments +CASCADE.worker manager -> ALERTING: issue + event reads CASCADE.worker manager -> SCM: PRs, reviews, comments SCM -> CASCADE.worker manager: repo + PR contents - diff --git a/docs/architecture/01-services.md b/docs/architecture/01-services.md index 0f46957bf..07305aca8 100644 --- a/docs/architecture/01-services.md +++ b/docs/architecture/01-services.md @@ -96,7 +96,7 @@ The router passes job data to workers via Docker container env vars: |----------|---------| | `JOB_ID` | Unique job identifier | | `JOB_TYPE` | `trello`, `github`, `jira`, `linear`, `sentry`, `manual-run`, `retry-run`, `debug-analysis` | -| `JOB_DATA` | JSON-encoded job payload | +| `JOB_DATA` | JSON-encoded job payload; GitHub jobs include `mergeabilityRecheckAttempt` in this payload for deferred re-checks | | `CASCADE_CREDENTIAL_KEYS` | Comma-separated list of credential env var names | | Individual credential vars | Pre-loaded project credentials (e.g., `GITHUB_TOKEN_IMPLEMENTER`) | @@ -132,7 +132,7 @@ The security scrub in step 8 prevents agent engines (which execute arbitrary LLM ### Dispatch flow `dispatchJob()` switches on the job type: -- **Webhook jobs** (`trello`, `github`, `jira`, `sentry`) — call the provider-specific webhook processor, which re-runs trigger dispatch and executes the matched agent +- **Webhook jobs** (`trello`, `github`, `jira`, `linear`, `sentry`) — call the provider-specific webhook processor, which re-runs trigger dispatch and executes the matched agent - **Dashboard jobs** (`manual-run`, `retry-run`, `debug-analysis`) — call `processDashboardJob()`, which loads project config and invokes the appropriate runner ## Dashboard diff --git a/docs/architecture/02-webhook-pipeline.md b/docs/architecture/02-webhook-pipeline.md index 17cedd11e..f818f2003 100644 --- a/docs/architecture/02-webhook-pipeline.md +++ b/docs/architecture/02-webhook-pipeline.md @@ -118,7 +118,7 @@ flowchart TD 4. **Self-check** — Adapter's `isSelfAuthored()` detects bot's own actions (loop prevention) 5. **Reaction** — Fire-and-forget emoji reaction on the source event 6. **Resolve config** — Look up project by platform identifier (board ID, repo, etc.) -7. **Dispatch triggers** — Within credential scope, call `TriggerRegistry.dispatch()` to find matching agent +7. **Dispatch triggers** — Within credential scope, call `TriggerRegistry.dispatch()` to find a matching agent. PM router adapters also wrap dispatch in `withPMScopeForDispatch(fullProject, dispatch)` so shared PM gates can resolve the active provider. 8. **Concurrency** — Check work-item lock (`work-item-lock.ts`) and agent-type concurrency (`agent-type-lock.ts`) 9. **Ack comment** — Post an acknowledgment comment to the work item or PR 10. **Build job** — Package trigger result + payload + ack info into a `CascadeJob` @@ -130,10 +130,11 @@ flowchart TD | Mechanism | File | Purpose | |-----------|------|---------| | Action dedup | `action-dedup.ts` | Prevent processing same webhook delivery twice | -| Work-item lock | `work-item-lock.ts` | Prevent concurrent agents on the same card/issue | +| Work-item lock | `work-item-lock.ts` | Prevent duplicate same-agent runs on the same card/issue | | Agent-type lock | `agent-type-lock.ts` | Configurable `max_concurrency` per agent type per project | +| Lock-state classifier | `lock-state-classifier.ts` | Explains blocked webhooks as queued, awaiting worker slot, or wedged lock | -All locks are in-memory with TTL expiry. They are conservative (enqueue-time only) — the worker performs its own verification before executing. +All locks are in-memory with TTL expiry. Work-item locks are scoped by `(projectId, workItemId, agentType)`: duplicate runs of the same agent are blocked, but different agent types can run concurrently on the same work item. When a lock rejects a webhook, logs distinguish `Awaiting worker slot` from `Work item locked (no active dispatch)`; the latter is a wedged-lock canary and captures to Sentry. ## Signature Verification diff --git a/docs/architecture/03-trigger-system.md b/docs/architecture/03-trigger-system.md index 213448bb6..c3b7adb25 100644 --- a/docs/architecture/03-trigger-system.md +++ b/docs/architecture/03-trigger-system.md @@ -57,8 +57,11 @@ interface TriggerResult { prNumber?: number; prUrl?: string; prTitle?: string; - waitForChecks?: boolean; // Poll CI before starting onBlocked?: () => void; // Cleanup if job can't be enqueued + deferredRecheck?: { + delayMs: number; + coalesceKey: string; + }; // Schedule a bare delayed re-dispatch } ``` @@ -135,7 +138,11 @@ function registerBuiltInTriggers(registry: TriggerRegistry): void { Triggers use category-prefixed events: `{category}:{event-name}` - `pm:status-changed`, `pm:label-added` - `scm:check-suite-success`, `scm:pr-review-submitted`, `scm:review-requested` -- `alerting:issue-created`, `alerting:metric-alert` +- `alerting:issue-alert`, `alerting:metric-alert` + +### Deferred re-checks + +Handlers that cannot make a final decision yet can return `deferredRecheck: { delayMs, coalesceKey }` with `agentType: null`. The router schedules a coalesced delayed BullMQ job and exits without spawning an agent. GitHub mergeability checks use this path; the worker recognizes re-check jobs via `mergeabilityRecheckAttempt` and captures a Sentry diagnostic if the second pass still cannot resolve state. ### Config resolution @@ -189,3 +196,4 @@ This includes: - Agent execution via `runAgent()` (see [05-engine-backends](./05-engine-backends.md)) - Post-run lifecycle (move card to "In Review", link PR, sync checklists) - Debug analysis triggering on failure +- Deterministic review dispatch after a successful implementation run with a PR, using the same dedup key as the `scm:check-suite-success` trigger diff --git a/docs/architecture/04-agent-system.md b/docs/architecture/04-agent-system.md index df4a7d0e1..89f3931eb 100644 --- a/docs/architecture/04-agent-system.md +++ b/docs/architecture/04-agent-system.md @@ -117,7 +117,7 @@ Key functions: | `respond-to-planning-comment` | fs, session, pm | Implementer | `pm:comment-mention` | | `backlog-manager` | fs, session, pm, scm:read | Implementer | `pm:status-changed` (backlog, merged) | | `resolve-conflicts` | fs, shell, session, scm | Implementer | `scm:pr-conflict-detected` | -| `alerting` | fs, shell, session, alerting, scm | Implementer | `alerting:issue-created`, `alerting:metric-alert` | +| `alerting` | fs, shell, session, alerting, scm | Implementer | `alerting:issue-alert`, `alerting:metric-alert` | | `debug` | fs, session, pm | Implementer | `internal:debug-analysis` | ## Capabilities diff --git a/docs/architecture/06-integration-layer.md b/docs/architecture/06-integration-layer.md index c9939274e..a6477a57f 100644 --- a/docs/architecture/06-integration-layer.md +++ b/docs/architecture/06-integration-layer.md @@ -1,12 +1,12 @@ # Integration Layer -CASCADE uses a unified integration abstraction so that infrastructure code (router, worker, webhook handlers) never branches on provider type. Every PM, SCM, and alerting provider is a class implementing `IntegrationModule`, registered into a singleton `IntegrationRegistry` at bootstrap. +CASCADE uses a unified integration layer so infrastructure code (router, worker, webhook handlers) looks up integrations through registries instead of branching on provider type. PM providers use the newer `PMProviderManifest` registry; SCM (GitHub) and alerting (Sentry) still use the legacy `IntegrationModule` path and mirror into the same cross-category `IntegrationRegistry`. ## IntegrationModule `src/integrations/types.ts` -The base contract for all integrations: +The base contract for SCM and alerting integrations, and the compatibility surface that PM manifests mirror into: ```typescript interface IntegrationModule { @@ -26,7 +26,7 @@ interface IntegrationModule { ### Credential scoping -`withCredentials()` uses `AsyncLocalStorage` to set provider-specific env vars for the duration of a callback, then restores the original values. This provides per-request credential isolation without global state mutation. +`withCredentials()` uses `AsyncLocalStorage` to set provider-specific credentials for the duration of a callback. This provides per-request credential isolation without global state mutation. ### Integration checking @@ -50,7 +50,11 @@ const integrationRegistry: IntegrationRegistry; // singleton ## Category Interfaces -### PMIntegration +### PMProviderManifest and PMIntegration + +`src/integrations/pm/manifest.ts` — the manifest is the single PM-provider contract. Trello, JIRA, and Linear each declare identity, credential roles, webhook route/signature verification, router adapter, trigger handlers, platform ack client, config schema, discovery capabilities, wizard spec, and lifecycle fixture in one provider-owned object. + +The PM barrel (`src/integrations/pm/index.ts`) imports each provider once, then mirrors each manifest's `pmIntegration` into `integrationRegistry`. New PM providers add one provider folder plus one import in that barrel; shared router, worker, dashboard, CLI, and config files are guarded against provider-specific edits by conformance tests. `src/pm/integration.ts` — extends `IntegrationModule` with PM-specific methods: @@ -72,16 +76,14 @@ const integrationRegistry: IntegrationRegistry; // singleton ## Bootstrap -`src/integrations/bootstrap.ts` +`src/integrations/entrypoint.ts` -Single, idempotent registration point for all five built-in integrations. Safe to import from router, worker, and dashboard — it does not pull in the agent execution pipeline or template files. +Single registration entrypoint for all runtime surfaces. Router, worker, CLI bootstrap, dashboard, and tests import it for side effects. ``` -TrelloIntegration → integrationRegistry + pmRegistry -JiraIntegration → integrationRegistry + pmRegistry -LinearIntegration → integrationRegistry + pmRegistry -GitHubSCMIntegration → integrationRegistry -SentryAlertingIntegration → integrationRegistry +PM barrel → pmProviderRegistry + integrationRegistry +GitHub register.ts → integrationRegistry + trigger handlers +Sentry register.ts → integrationRegistry + trigger handlers ``` ## Credential Roles @@ -100,26 +102,29 @@ Each provider declares its credential roles — the mapping from logical role na ## Provider Implementations -### Trello (`src/pm/trello/`, `src/trello/`) +### Trello (`src/integrations/pm/trello/`, `src/pm/trello/`, `src/trello/`) -- `TrelloIntegration` implements `PMIntegration` +- `trelloManifest` declares the PM provider contract and registers with `pmProviderRegistry` +- `TrelloIntegration` implements the mirrored `PMIntegration` - `TrelloPMProvider` implements `PMProvider` (card CRUD, comments, labels, checklists) - `trelloClient` — Octokit-style client with AsyncLocalStorage credential scoping - Media extraction from markdown in card descriptions/comments - Status = list ID (cards grouped by lists) -### JIRA (`src/pm/jira/`, `src/jira/`) +### JIRA (`src/integrations/pm/jira/`, `src/pm/jira/`, `src/jira/`) -- `JiraIntegration` implements `PMIntegration` +- `jiraManifest` declares the PM provider contract and registers with `pmProviderRegistry` +- `JiraIntegration` implements the mirrored `PMIntegration` - `JiraPMProvider` implements `PMProvider` (issue CRUD, transitions, comments) - `jiraClient` — wraps `jira.js` Version3Client with AsyncLocalStorage scoping - ADF (Atlassian Document Format) ↔ markdown conversion (`src/pm/jira/adf.ts`) - Status transitions via JIRA transition ID lookup - Issue key extraction via regex: `[A-Z][A-Z0-9]+-\d+` -### Linear (`src/pm/linear/`, `src/linear/`) +### Linear (`src/integrations/pm/linear/`, `src/pm/linear/`, `src/linear/`) -- `LinearIntegration` implements `PMIntegration` +- `linearManifest` declares the PM provider contract and registers with `pmProviderRegistry` +- `LinearIntegration` implements the mirrored `PMIntegration` - `LinearPMProvider` implements `PMProvider` (issue CRUD, comments, labels, state transitions) - `linearClient` — GraphQL/REST client with AsyncLocalStorage credential scoping - Status transitions via Linear state ID lookup diff --git a/docs/architecture/07-gadgets.md b/docs/architecture/07-gadgets.md index 3d1ab228b..acfcdcfc7 100644 --- a/docs/architecture/07-gadgets.md +++ b/docs/architecture/07-gadgets.md @@ -101,10 +101,10 @@ Native-tool engines cannot invoke gadget classes directly (they run as subproces | Category | Commands | Example | |----------|----------|---------| -| PM | `cascade-tools pm read-card`, `list-cards`, `update-card`, etc. | `cascade-tools pm read-card --cardId=abc123 --raw-json` | -| SCM | `cascade-tools github get-pr-details`, `get-diff`, `post-comment`, etc. | `cascade-tools github get-pr-details --pr-number=42` | -| Alerting | `cascade-tools sentry get-issue`, `list-events`, etc. | `cascade-tools sentry get-issue --issue-id=12345` | -| Session | `cascade-tools session todo-upsert`, `todo-status`, etc. | `cascade-tools session todo-upsert --id=1 --title="Fix tests"` | +| PM | `cascade-tools pm read-work-item`, `list-work-items`, `update-work-item`, etc. | `cascade-tools pm read-work-item --workItemId abc123` | +| SCM | `cascade-tools scm get-pr-details`, `get-pr-diff`, `post-pr-comment`, etc. | `cascade-tools scm get-pr-details --prNumber 42` | +| Alerting | `cascade-tools alerting get-alerting-issue`, `list-alerting-events`, etc. | `cascade-tools alerting get-alerting-issue --organizationId acme --issueId 12345` | +| Session | `cascade-tools session finish` | `cascade-tools session finish --comment "Created PR and verified checks"` | The `cascade-tools` binary uses a separate oclif config (`bin/cascade-tools.js`) that discovers all non-dashboard commands, while `cascade` discovers only dashboard commands. diff --git a/docs/architecture/08-config-credentials.md b/docs/architecture/08-config-credentials.md index ad082fb29..54b456f0e 100644 --- a/docs/architecture/08-config-credentials.md +++ b/docs/architecture/08-config-credentials.md @@ -51,14 +51,18 @@ interface ProjectConfig { agentEngineSettings?: Record; runLinksEnabled: boolean; maxInFlightItems?: number; // hard cap on TODO+IN_PROGRESS+IN_REVIEW; default 1 - // ... PM config (trello/jira), agent models, snapshot settings + // ... PM config (trello/jira/linear), agent models, snapshot settings } ``` `maxInFlightItems` is enforced at two points: (a) the `backlog-manager` chain gates (won't auto-pull from BACKLOG when at capacity) and (b) the PM `status-changed` triggers (won't fire `implementation` when a card is moved -into TODO past the cap). See `src/triggers/shared/pipeline-capacity-gate.ts`. +into TODO past the cap). PM router adapters must dispatch inside +`withPMScopeForDispatch(fullProject, dispatch)` so this gate can resolve the +active PM provider; if that scope is missing the gate fails closed and captures +Sentry under `pipeline_capacity_gate_no_pm_provider`. See +`src/triggers/shared/pipeline-capacity-gate.ts`. ## Credential Resolution diff --git a/docs/architecture/09-database.md b/docs/architecture/09-database.md index 55ba8f088..abcbe238d 100644 --- a/docs/architecture/09-database.md +++ b/docs/architecture/09-database.md @@ -134,7 +134,7 @@ erDiagram | `agent_run_logs` | Cascade log + engine log per run | One-to-one with `agent_runs` | | `agent_run_llm_calls` | LLM request/response pairs with token/cost tracking | — | | `prompt_partials` | Org-scoped prompt template customizations | UNIQUE(`org_id`, `name`) | -| `pr_work_items` | Maps PRs to work items for run-link display | — | +| `pr_work_items` | Maps PRs and external alert sources to PM work items for run-link display and alert idempotency | Partial unique indexes on `(project_id, pr_number)`, `(project_id, work_item_id)`, and `(project_id, external_source, external_id)` when those values are present | | `webhook_logs` | Raw webhook payloads for debugging | — | | `users` | Dashboard users (email, bcrypt hash, role) | Org-scoped | | `sessions` | Session tokens for cookie auth (30-day expiry) | — | diff --git a/docs/architecture/10-resilience.md b/docs/architecture/10-resilience.md index 8123e192e..2436fed5e 100644 --- a/docs/architecture/10-resilience.md +++ b/docs/architecture/10-resilience.md @@ -27,8 +27,9 @@ startWatchdog(timeoutMs, () => { Prevents multiple agents from working on the same card/issue simultaneously. The lock is in-memory (router process) with TTL expiry. - Checked at webhook processing time (step 8 of the pipeline) -- Marked when job is enqueued, cleared when worker completes +- Marked when a job is enqueued, cleared when the worker completes or when dispatch failure compensation runs - Key: `(projectId, workItemId, agentType)` +- Only same-agent duplicates are blocked; different agent types may run concurrently on the same work item ### Agent-type concurrency limit @@ -48,6 +49,8 @@ Configurable `max_concurrency` per agent type per project (set via `agent_config The router's worker manager limits how many Docker containers run in parallel via `routerConfig.maxWorkers`. +When the pool is full, dispatch waits for a slot via `slot-waiter.ts` for `SLOT_WAIT_TIMEOUT_MS` (default 5 minutes). A timeout is classified as transient, so BullMQ retries it under the bounded queue retry policy. + ## Rate Limiting `src/config/rateLimits.ts` @@ -62,6 +65,17 @@ Rate limits are enforced by the LLMist SDK for `sdk`-archetype engines. Native-t ## Retry Strategy +### Dispatch retries + +The router queues `cascade-jobs` and `cascade-dashboard-jobs` with `attempts: 4` and exponential backoff. Dispatch errors before a worker container starts are classified in `src/router/dispatch-error-classifier.ts`: + +- Transient: Docker socket `ECONNREFUSED` / `ECONNRESET` / `ENOTFOUND`, registry HTTP 429, container-name HTTP 409, and `SLOT_WAIT_TIMEOUT`. +- Terminal: validation errors (`TypeError`, `ZodError`) and image-not-found after fallback exhaustion. + +Every failed dispatch path flows through the BullMQ `failed` event and calls `releaseLocksForFailedJob`, releasing the work-item lock, agent-type counter, and recently-dispatched mark. Webhook logs distinguish healthy backpressure (`Awaiting worker slot`) from the wedged-lock canary (`Work item locked (no active dispatch)`). + +### LLM/API retries + `src/config/retryConfig.ts` Handles transient LLM API failures: @@ -131,6 +145,8 @@ See [08-config-credentials](./08-config-credentials.md) — AES-256-GCM encrypti Periodic scan for Docker containers that outlived their expected lifetime (watchdog timeout + buffer). Orphans are killed and their run records marked as failed. +When a worker container exits non-zero, the router inspects it before Docker AutoRemove can reap it and writes a grep-stable error reason: `Worker crashed with exit code N · OOMKilled= · reason=""`. `OOMKilled=true` is the definitive cgroup OOM signal; exit 137 without that marker means something else sent the signal. + ## Snapshot Management `src/router/snapshot-manager.ts`, `src/router/snapshot-cleanup.ts` diff --git a/docs/cascade-directory.md b/docs/cascade-directory.md index 6e31c4d54..e9e60be73 100644 --- a/docs/cascade-directory.md +++ b/docs/cascade-directory.md @@ -13,6 +13,7 @@ None of these files are required — CASCADE works without them — but they giv | [`setup.sh`](#-setupsh) | You | Install deps, run migrations, prepare the workspace | | [`on-file-edit.sh`](#-on-file-editsh) | You | Post-edit hook — lint/typecheck a single file | | [`on-verify.sh`](#-on-verifysh) | You | Verification suite — run tests or a broader check | +| [`ensure-services.sh`](#-ensure-servicessh) | You | Restart local services needed by tests after long sessions | | [`env`](#-env) | You | Extra environment variables for the agent session | | [`context/`](#-context) | CASCADE | Temporary context files (auto-created and cleaned up) | @@ -136,6 +137,26 @@ esac --- +## 🩺 `ensure-services.sh` + +**When it runs:** Only when the agent explicitly invokes it after a command fails because a local service is unavailable. + +**What it does:** Restarts or verifies external services needed by the repository's tests, such as PostgreSQL, Redis, Docker Compose stacks, or local emulators. CASCADE does not run this file automatically; agent instructions tell the agent to look for `.cascade/ensure-services.sh` when tests fail with connection errors. + +**Example:** + +```bash +#!/usr/bin/env bash +set -euo pipefail + +docker compose -f docker-compose.test.yml up -d --wait +redis-cli ping >/dev/null +``` + +Keep this script idempotent and make it print enough progress that long service startup is distinguishable from a hang. + +--- + ## 🌐 `env` **When it is loaded:** At the start of each agent session, before setup and before the agent runs. diff --git a/docs/getting-started.md b/docs/getting-started.md index 79d38b22a..6be0c1076 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -284,11 +284,13 @@ node bin/cascade.js projects integration-set my-project \ --config '{"teamId":"TEAM_UUID","statuses":{"todo":"STATE_UUID","inProgress":"STATE_UUID","done":"STATE_UUID"},"labels":{"readyToProcess":"LABEL_UUID","processing":"LABEL_UUID"}}' ``` +If you enable the alerting agent, configure the optional `alerts` PM slot as well. For Trello this is `lists.alerts`; for Jira and Linear this is `statuses.alerts`. Sentry alerts materialize into that list/status before the alerting agent runs. + --- ## 9. Set Up Webhooks -Cascade needs to receive webhooks from GitHub (and optionally your PM tool) to trigger agents. +Cascade needs to receive webhooks from GitHub, your PM tool, and optionally Sentry to trigger agents. Your Cascade instance must be reachable from the internet. For local development, use a tunnel like [ngrok](https://ngrok.com/) or [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/). @@ -305,7 +307,15 @@ node bin/cascade.js webhooks create my-project \ --callback-url https://your-tunnel.ngrok.io ``` -This creates webhooks on GitHub (and Trello if configured) pointing to your Router. For Linear, create the webhook manually in your Linear workspace settings, pointing to `https://your-router-host/linear/webhook`. +This creates webhooks on GitHub, Trello, and Jira when those integrations are configured, reusing existing hooks when the canonical callback URL already exists. Linear and Sentry are informational/manual setup paths: the dashboard and API show the correct callback URL and whether a signing secret is stored, but you create the webhook in the provider UI. + +| Provider | Setup behavior | Callback URL | +|----------|----------------|--------------| +| GitHub | Programmatic create/list/delete | `https://your-router-host/github/webhook` | +| Trello | Programmatic create/list/delete | `https://your-router-host/trello/webhook` | +| Jira | Programmatic create/list/delete plus label ensure | `https://your-router-host/jira/webhook` | +| Linear | Manual setup with optional `LINEAR_WEBHOOK_SECRET` | `https://your-router-host/linear/webhook` | +| Sentry | Manual setup with optional Sentry webhook secret | `https://your-router-host/sentry/webhook/my-project` | --- @@ -335,6 +345,10 @@ node bin/cascade.js projects trigger-set my-project \ node bin/cascade.js projects trigger-set my-project \ --agent respond-to-review --event scm:pr-review-submitted --enable +# Enable alerting investigations for Sentry issue alerts +node bin/cascade.js projects trigger-set my-project \ + --agent alerting --event alerting:issue-alert --enable + # See all available triggers for an agent node bin/cascade.js projects trigger-discover --agent implementation ``` diff --git a/src/gadgets/README.md b/src/gadgets/README.md index 92d46fea8..28e620d43 100644 --- a/src/gadgets/README.md +++ b/src/gadgets/README.md @@ -4,6 +4,8 @@ This document is the canonical guide for **adding a new gadget** (a `cascade-tools ` command) and keeping its agent-facing surface truthful, runnable, and self-correctable. +Current command namespaces are category-based: `pm` for work items and checklists, `scm` for GitHub PR operations, `alerting` for Sentry issue/event reads, and `session` for run completion. Keep examples aligned with the concrete command files under `src/cli//`. + --- ## Architecture in one picture diff --git a/src/integrations/README.md b/src/integrations/README.md index 449aa2043..4e8ce5840 100644 --- a/src/integrations/README.md +++ b/src/integrations/README.md @@ -192,7 +192,7 @@ All three real providers are now on the hardened contracts. Plan 009/4 also ship | Area | Change | |---|---| | Wizard migration | All three production providers (Trello, JIRA, Linear) now render every standard wizard step through the shared components. The three legacy `pm-wizard-{trello,jira,linear}-steps.tsx` files are **deleted**. Zero per-provider step UI outside of explicit `kind: 'custom'` steps (Trello OAuth, JIRA issue-type). | -| Parent wizard | `pm-wizard.tsx` now iterates over `manifestDef.steps` dynamically — the old spec-006-era "3 hardcoded stepIndex slots" layout is gone. Each manifest step gets its own WizardStep slot. The legacy `WebhookStep` remains for now (programmatic webhook registration for Trello/JIRA + Linear signing-secret UX); migration of that into the manifest path is follow-up scope. | +| Parent wizard | `pm-wizard.tsx` now iterates over `manifestDef.steps` dynamically — the old spec-006-era "3 hardcoded stepIndex slots" layout is gone. Each manifest step gets its own WizardStep slot. The legacy `WebhookStep` was retained temporarily for programmatic webhook registration (Trello/JIRA) and signing-secret UX (Linear); it was fully migrated into the manifest path in spec 012 (see Post-spec-012 additions below). | | 7th StandardStepKind | `custom-field-mapping` shared component (with optional `onCreateCustomField` + `fieldDefaults` props) wires `manifest.createCustomField`. Trello and JIRA use it; Linear doesn't have a custom-field concept. | | Shared-component widenings (additive) | `container-pick` and `project-scope` support optional `searchable: boolean` (renders via cmdk `Combobox`). `webhook-url-display` supports optional inline signing-secret input (`secretFieldRole` / `secretValue` / `onSecretChange`). `label-mapping` supports optional `labelDefaults?` to pre-populate the Create input + thread color. `custom-field-mapping` supports optional `fieldDefaults?`. | | Shared surface guard | Step-component file pin extended to seven entries. | @@ -250,7 +250,7 @@ Different PM providers have different native concepts of "checklist". The `PMPro | **Linear** | Inline markdown in description | `### {Checklist Name}` heading + `- [ ]` / `- [x]` lines in the issue's description | | **JIRA** | Inline markdown in description (via ADF round-trip) | `### {Checklist Name}` heading + `- [ ]` / `- [x]` lines in the issue's description | -**Why inline markdown for Linear and JIRA?** Both providers support markdown checkboxes natively in their description editors but lack a dedicated lightweight checklist primitive — sub-issues and subtasks are full work items, which clutters boards when used for things like acceptance criteria or implementation steps. Inline markdown matches Trello's lightweight semantics without creating orphan issues. See [spec 008](../../docs/specs/008-inline-checklists.md) for full rationale. +**Why inline markdown for Linear and JIRA?** Both providers support markdown checkboxes natively in their description editors but lack a dedicated lightweight checklist primitive — sub-issues and subtasks are full work items, which clutters boards when used for things like acceptance criteria or implementation steps. Inline markdown matches Trello's lightweight semantics without creating orphan issues. See [spec 008](../../docs/specs/008-inline-checklists.md.done) for full rationale. The shared engine that parses, appends, toggles, and removes inline checklist items lives at `src/pm/_shared/inline-checklist.ts` and is consumed by both the Linear and JIRA adapters. @@ -306,13 +306,13 @@ Spec 016/3 captured a fixture and pinned the rule for Linear specifically. The f - **Regression net.** The captured fixture lives at `tests/fixtures/linear-issue-with-screenshot.json`. The unit test at `tests/unit/pm/linear/extraction-coverage.test.ts` loads the fixture and asserts every inline image is extracted — fails LOUDLY with a clear message if Linear ever changes payload shape in a way that loses inline images. - **No new GraphQL surface to query.** As of spec 016/3 the Linear API exposes inline-pasted images only via the `description` and `Comment.body` markdown fields. There is no `descriptionData` rich-text JSON tree that would expose them differently, and no `attachments(includeInline: true)` filter. Future Linear API drift would surface as a fixture-test failure. -See [spec 016](../../docs/specs/016-pm-image-delivery-reliability.md) for the full rationale and the live incident this contract closed. +See [spec 016](../../docs/specs/016-pm-image-delivery-reliability.md.done) for the full rationale and the live incident this contract closed. --- ## Alerting work-item materializer -**Spec [019](../../docs/specs/019-sentry-alert-pm-materialization.md)** added a generic materializer that converts an external alert event into a real PM work item so the alerting agent runs against a native PM card/issue with full lifecycle support (budget tracking, status transitions, label writes). See the spec for full rationale; this section covers the contracts new providers must respect. +**Spec [019](../../docs/specs/019-sentry-alert-pm-materialization.md.done)** added a generic materializer that converts an external alert event into a real PM work item so the alerting agent runs against a native PM card/issue with full lifecycle support (budget tracking, status transitions, label writes). See the spec for full rationale; this section covers the contracts new providers must respect. ### `materializeAlertWorkItem` contract @@ -330,13 +330,13 @@ Located at `src/integrations/alerting/_shared/materialize.ts`. Callable from any The function throws `AlertSlotMissingError` (from `src/integrations/alerting/_shared/types.ts`) when the project's PM config doesn't have the `alerts` slot configured. Callers catch this and return `null` — no dispatch, operator must configure the slot. -### Storage contract — `work_items` idempotency +### Storage contract — `pr_work_items` idempotency -Each materialization writes a row to the `work_items` table (or updates the existing one) using the `(projectId, externalSource, externalId)` partial UNIQUE index: +Each materialization writes a row to the `pr_work_items` table (or updates the existing one) using the `(projectId, externalSource, externalId)` partial UNIQUE index: ```sql -CREATE UNIQUE INDEX work_items_external_uniq - ON work_items (project_id, external_source, external_id) +CREATE UNIQUE INDEX uq_pr_work_items_project_external + ON pr_work_items (project_id, external_source, external_id) WHERE external_source IS NOT NULL; ``` diff --git a/tests/README.md b/tests/README.md index 03d53b86a..9a48c5b48 100644 --- a/tests/README.md +++ b/tests/README.md @@ -66,6 +66,8 @@ npx vitest run tests/unit/triggers/trello/status-changed.test.ts TEST_DATABASE_URL=... npx vitest run --project integration tests/integration/.test.ts ``` +Documentation drift guards live in `tests/unit/architecture-docs.test.ts` and run under `unit-core`. They check architecture deep-dive structure, active Markdown relative links (including links to archived `.md.done` specs), canonical trigger names such as `alerting:issue-alert`, current `cascade-tools` namespaces, and `CLAUDE.md`/`AGENTS.md` synchronization. + --- ## Factory Catalog diff --git a/tests/unit/architecture-docs.test.ts b/tests/unit/architecture-docs.test.ts index 037eef5e3..15bfd70b2 100644 --- a/tests/unit/architecture-docs.test.ts +++ b/tests/unit/architecture-docs.test.ts @@ -1,18 +1,51 @@ -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import path from 'node:path'; +import { TRIGGER_EVENTS } from '../../src/triggers/shared/events.js'; +const REPO_ROOT = path.resolve(__dirname, '../..'); const DOCS_ROOT = path.resolve(__dirname, '../../docs'); const ARCH_DIR = path.join(DOCS_ROOT, 'architecture'); +const ROOT_DOCS = ['README.md', 'CLAUDE.md', 'AGENTS.md']; +const EXTRA_ACTIVE_DOCS = [ + 'src/integrations/README.md', + 'src/gadgets/README.md', + 'src/backends/README.md', + 'tests/README.md', +]; function readDoc(filePath: string): string { return readFileSync(filePath, 'utf-8'); } function extractMarkdownLinks(content: string): string[] { - const linkPattern = /\[.*?\]\((\.\.?\/[^)]+\.md)\)/g; + const linkPattern = /\[[^\]]+\]\((\.\.?\/[^)\s]+\.md(?:\.done)?(?:#[^)]+)?)\)/g; return Array.from(content.matchAll(linkPattern), (m) => m[1]); } +function listMarkdownDocs(dir: string): string[] { + const entries = readdirSync(dir); + return entries.flatMap((entry) => { + const fullPath = path.join(dir, entry); + const stats = statSync(fullPath); + if (stats.isDirectory()) return listMarkdownDocs(fullPath); + if (entry.endsWith('.md')) return [fullPath]; + return []; + }); +} + +function activeMarkdownDocs(): string[] { + return [ + ...ROOT_DOCS.map((file) => path.join(REPO_ROOT, file)), + ...EXTRA_ACTIVE_DOCS.map((file) => path.join(REPO_ROOT, file)), + ...listMarkdownDocs(DOCS_ROOT), + ]; +} + +function resolveMarkdownLink(fromFile: string, link: string): string { + const [target] = link.split('#'); + return path.resolve(path.dirname(fromFile), target); +} + describe('Architecture documentation', () => { describe('hub document (ARCHITECTURE.md)', () => { const hubPath = path.join(DOCS_ROOT, 'ARCHITECTURE.md'); @@ -142,7 +175,7 @@ describe('Architecture documentation', () => { expect(links.length).toBeGreaterThan(0); for (const link of links) { - const resolved = path.resolve(DOCS_ROOT, link); + const resolved = resolveMarkdownLink(hubPath, link); expect(existsSync(resolved)).toBe(true); } }); @@ -154,10 +187,56 @@ describe('Architecture documentation', () => { const content = readDoc(filePath); const links = extractMarkdownLinks(content); for (const link of links) { - const resolved = path.resolve(ARCH_DIR, link); + const resolved = resolveMarkdownLink(filePath, link); expect(existsSync(resolved)).toBe(true); } } }); + + it('all relative .md and .md.done links in active docs resolve to existing files', () => { + for (const filePath of activeMarkdownDocs()) { + const links = extractMarkdownLinks(readDoc(filePath)); + for (const link of links) { + const resolved = resolveMarkdownLink(filePath, link); + expect(existsSync(resolved), `${filePath} links to missing ${link}`).toBe(true); + } + } + }); + }); + + describe('canonical documentation facts', () => { + it('uses the canonical alerting issue trigger event in active docs', () => { + const staleEvent = 'alerting:issue-created'; + for (const filePath of activeMarkdownDocs()) { + const content = readDoc(filePath); + expect(content, `${filePath} should not mention ${staleEvent}`).not.toContain(staleEvent); + } + expect(TRIGGER_EVENTS.ALERTING.ISSUE_ALERT).toBe('alerting:issue-alert'); + }); + + it('documents current cascade-tools namespaces and work-item terminology', () => { + const stalePatterns = [ + /cascade-tools\s+github\b/, + /cascade-tools\s+sentry\b/, + /\bread-card\b/, + /\blist-cards\b/, + /\bupdate-card\b/, + /--cardId\b/, + /\bwork_items\b/, + ]; + + for (const filePath of activeMarkdownDocs()) { + const content = readDoc(filePath); + for (const pattern of stalePatterns) { + expect(content, `${filePath} should not match ${pattern}`).not.toMatch(pattern); + } + } + }); + + it('keeps AGENTS.md synchronized with CLAUDE.md', () => { + const agents = readDoc(path.join(REPO_ROOT, 'AGENTS.md')); + const claude = readDoc(path.join(REPO_ROOT, 'CLAUDE.md')); + expect(agents).toBe(claude); + }); }); }); From 670f26f14fe09e9ab7e1ef1ed814c457a0122690 Mon Sep 17 00:00:00 2001 From: aaight Date: Sat, 9 May 2026 11:26:28 +0200 Subject: [PATCH 05/18] refactor(router): decompose webhook trigger outcomes (#1278) Co-authored-by: Cascade Bot --- src/router/webhook-dispatch-locks.ts | 131 +++++++++ src/router/webhook-processor.ts | 381 +------------------------ src/router/webhook-trigger-outcomes.ts | 347 ++++++++++++++++++++++ 3 files changed, 483 insertions(+), 376 deletions(-) create mode 100644 src/router/webhook-dispatch-locks.ts create mode 100644 src/router/webhook-trigger-outcomes.ts diff --git a/src/router/webhook-dispatch-locks.ts b/src/router/webhook-dispatch-locks.ts new file mode 100644 index 000000000..a1843773d --- /dev/null +++ b/src/router/webhook-dispatch-locks.ts @@ -0,0 +1,131 @@ +import { captureException } from '../sentry.js'; +import type { TriggerResult } from '../types/index.js'; +import { logger } from '../utils/logging.js'; +import { + checkAgentTypeConcurrency, + markAgentTypeEnqueued, + markRecentlyDispatched, +} from './agent-type-lock.js'; +import { classifyLockState } from './lock-state-classifier.js'; +import { isWorkItemLocked, markWorkItemEnqueued } from './work-item-lock.js'; + +export interface DispatchLockCheckResult { + blocked: boolean; + decisionReason?: string; + effectiveLockKey?: string; + agentTypeMaxConcurrency: number | null; +} + +export async function checkDispatchLocks({ + adapterType, + projectId, + result, +}: { + adapterType: string; + projectId: string; + result: TriggerResult & { agentType: string }; +}): Promise { + const effectiveLockKey = result.lockKey ?? result.workItemId; + if (effectiveLockKey) { + const lockStatus = await isWorkItemLocked(projectId, effectiveLockKey, result.agentType); + if (lockStatus.locked) { + result.onBlocked?.(); + logger.info(`Skipping ${adapterType} job — work item already locked`, { + source: adapterType, + projectId, + workItemId: effectiveLockKey, + blockedAgentType: result.agentType, + reason: lockStatus.reason, + }); + const classification = await classifyLockState({ + projectId, + workItemId: effectiveLockKey, + agentType: result.agentType, + }); + const reasonSuffix = lockStatus.reason ?? 'active run exists'; + if (classification === 'wedged') { + captureException( + new Error( + `wedged work-item lock: projectId=${projectId} workItemId=${effectiveLockKey} agentType=${result.agentType}`, + ), + { + tags: { source: 'wedged_lock_canary' }, + extra: { + projectId, + workItemId: effectiveLockKey, + agentType: result.agentType, + reason: lockStatus.reason, + }, + }, + ); + return { + blocked: true, + decisionReason: `Work item locked (no active dispatch): ${reasonSuffix}`, + agentTypeMaxConcurrency: null, + }; + } + return { + blocked: true, + decisionReason: `Awaiting worker slot: ${reasonSuffix}`, + agentTypeMaxConcurrency: null, + }; + } + } + + const concurrencyCheck = await checkAgentTypeConcurrency( + projectId, + result.agentType, + adapterType, + result.workItemId, + ); + if (concurrencyCheck.blocked) { + result.onBlocked?.(); + return { + blocked: true, + decisionReason: 'Agent type concurrency limit reached', + effectiveLockKey, + agentTypeMaxConcurrency: concurrencyCheck.maxConcurrency, + }; + } + + return { + blocked: false, + effectiveLockKey, + agentTypeMaxConcurrency: concurrencyCheck.maxConcurrency, + }; +} + +export function markImmediateDispatchEnqueued({ + projectId, + result, + effectiveLockKey, + agentTypeMaxConcurrency, +}: { + projectId: string; + result: TriggerResult & { agentType: string }; + effectiveLockKey?: string; + agentTypeMaxConcurrency: number | null; +}): void { + if (effectiveLockKey) { + markWorkItemEnqueued(projectId, effectiveLockKey, result.agentType); + } + if (agentTypeMaxConcurrency !== null) { + markRecentlyDispatched(projectId, result.agentType, result.workItemId); + markAgentTypeEnqueued(projectId, result.agentType); + } +} + +export function markCoalescedDispatchEnqueued({ + projectId, + result, +}: { + projectId: string; + result: TriggerResult & { agentType: string }; +}): void { + const coalescedLockKey = result.lockKey ?? result.workItemId; + if (coalescedLockKey) { + markWorkItemEnqueued(projectId, coalescedLockKey, result.agentType); + } + markRecentlyDispatched(projectId, result.agentType, result.workItemId); + markAgentTypeEnqueued(projectId, result.agentType); +} diff --git a/src/router/webhook-processor.ts b/src/router/webhook-processor.ts index 5041c70cd..a21dafb5f 100644 --- a/src/router/webhook-processor.ts +++ b/src/router/webhook-processor.ts @@ -10,52 +10,14 @@ * from `pm/webhook-handler.ts` but for the router (enqueue-only) path. */ -import { getCoalesceWindowMs } from '../pm/coalesce-config.js'; -import { captureException } from '../sentry.js'; import type { TriggerRegistry } from '../triggers/registry.js'; -import type { TriggerResult } from '../types/index.js'; import { logger } from '../utils/logging.js'; import { isDuplicateAction, markActionProcessed } from './action-dedup.js'; +import type { RouterPlatformAdapter } from './platform-adapter.js'; import { - checkAgentTypeConcurrency, - clearAgentTypeEnqueued, - clearRecentlyDispatched, - markAgentTypeEnqueued, - markRecentlyDispatched, -} from './agent-type-lock.js'; -import { classifyLockState } from './lock-state-classifier.js'; -import type { ParsedWebhookEvent, RouterPlatformAdapter } from './platform-adapter.js'; -import { addJob, scheduleCoalescedJob } from './queue.js'; -import { clearWorkItemEnqueued, isWorkItemLocked, markWorkItemEnqueued } from './work-item-lock.js'; - -/** - * Pick the most specific work-item label for a webhook log decisionReason. - * - * `event.workItemId` is set at parse time (`adapter.parseWebhook`) — for - * GitHub `pull_request`-shaped events the parser populates it from - * `payload.pull_request.number`, but for `check_suite` webhooks the PR - * number lives under `payload.check_suite.pull_requests[0].number` and the - * parser leaves the field undefined. The trigger handler resolves it - * internally and returns `result.workItemId` / `result.prNumber`; both are - * better diagnostic labels than `(unknown)` and the dashboard webhook log - * should prefer them. - * - * Order: result.workItemId > `PR #` > event.workItemId > `(unknown)`. - */ -function resolveWorkItemLabel(result: TriggerResult, event: ParsedWebhookEvent): string { - if (result.workItemId) return result.workItemId; - if (typeof result.prNumber === 'number') return `PR #${result.prNumber}`; - return event.workItemId ?? '(unknown)'; -} - -export interface ProcessRouterWebhookResult { - /** Whether the event was of a processable type for this platform. */ - shouldProcess: boolean; - /** The resolved project identifier, if any. */ - projectId?: string; - /** Human-readable explanation of why the event was processed or skipped. */ - decisionReason?: string; -} + handleTriggerOutcome, + type ProcessRouterWebhookResult, +} from './webhook-trigger-outcomes.js'; /** * Process a single incoming webhook through the full router pipeline. @@ -73,7 +35,6 @@ export interface ProcessRouterWebhookResult { * 11. Fire optional pre-actions (e.g. GitHub 👀 reaction) * 12. Enqueue job to Redis (durable) */ -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: webhook pipeline with sequential guard checks export async function processRouterWebhook( adapter: RouterPlatformAdapter, payload: unknown, @@ -155,337 +116,5 @@ export async function processRouterWebhook( }; } - // Structured skip — a matched handler ran but bailed (e.g. precondition - // unmet, dedup claim lost, PR not from cascade persona). Surface the - // handler-specific reason in webhook log decisionReason so operators can - // triage from the dashboard without trawling cascade-router process logs. - if (result.skipReason && result.agentType === null) { - logger.info(`${adapter.type} trigger self-skipped`, { - handler: result.skipReason.handler, - message: result.skipReason.message, - eventType: event.eventType, - workItemId: event.workItemId, - projectId: project.id, - }); - return { - shouldProcess: true, - projectId: project.id, - decisionReason: `Trigger ${result.skipReason.handler} skipped: ${result.skipReason.message}`, - }; - } - - // Step 7c: Deferred re-check — trigger asked the router to retry this event - // after a delay. Schedules a bare job (no embedded triggerResult) so the worker - // re-dispatches fresh via the trigger registry. Used for GitHub async state that - // resolves after the webhook delivery window (e.g. PR mergeability). - if (result.deferredRecheck && result.agentType === null) { - const job = adapter.buildJob(event, payload, project, result, undefined); - try { - await scheduleCoalescedJob( - job, - result.deferredRecheck.coalesceKey, - result.deferredRecheck.delayMs, - ); - logger.info(`${adapter.type} deferred re-check scheduled`, { - coalesceKey: result.deferredRecheck.coalesceKey, - delayMs: result.deferredRecheck.delayMs, - projectId: project.id, - }); - } catch (err) { - captureException(err instanceof Error ? err : new Error(String(err)), { - tags: { source: 'deferred_recheck_schedule_failure' }, - extra: { coalesceKey: result.deferredRecheck.coalesceKey, projectId: project.id }, - }); - logger.error(`Failed to schedule deferred re-check for ${adapter.type} event`, { - error: String(err), - coalesceKey: result.deferredRecheck.coalesceKey, - }); - } - return { - shouldProcess: true, - projectId: project.id, - decisionReason: `Deferred re-check scheduled: ${result.deferredRecheck.coalesceKey}`, - }; - } - - logger.info(`${adapter.type} trigger matched`, { - agentType: result.agentType || '(no agent)', - workItemId: event.workItemId, - projectId: project.id, - }); - - // Step 7b: BullMQ delayed-job coalescing for PM status-change sequences. - // - // Any dispatch for the same coalesceKey (${projectId}:${workItemId}) within - // the settle window supersedes the prior pending dispatch — regardless of - // agent type or whether the event is a create vs. update. The ack comment - // is deferred to job fire time (pendingAck=true) so no orphaned ack comment - // is left behind when a job is superseded. - if (result.coalesceKey && result.agentType) { - const windowMs = getCoalesceWindowMs(); - if (windowMs > 0) { - // Build the job without ack info (ack will be posted at fire time). - const job = adapter.buildJob(event, payload, project, result, undefined); - - // Attach the deferred-ack marker. Store workItemTitle as a context - // hint (not a literal comment) — the worker calls generateAckMessage() - // at fire time to produce a proper role-aware ack message. Storing - // the title lets generateAckMessage fall back gracefully when the - // full payload context extractor returns nothing. - if (job.type === 'trello' || job.type === 'jira' || job.type === 'linear') { - job.pendingAck = true; - job.ackContextHint = result.workItemTitle ?? undefined; - } - - // Schedule as a delayed BullMQ job; supersedes any prior pending job - // with the same key so only the latest event fires within the window. - // Each schedule produces a UNIQUE jobId — active/completed/failed jobs - // for the same coalesceKey do NOT block a new schedule (the prior - // deterministic-id design silently dropped events; see the - // `scheduleCoalescedJob` JSDoc for the live MNG-422 incident). - try { - const { superseded, supersededJobData } = await scheduleCoalescedJob( - job, - result.coalesceKey, - windowMs, - ); - - if (superseded) { - logger.info(`${adapter.type} coalesced dispatch superseded prior pending job`, { - agentType: result.agentType, - workItemId: result.workItemId, - projectId: project.id, - coalesceKey: result.coalesceKey, - }); - // Release in-memory locks for the superseded job to prevent phantom - // lock entries from accumulating. existing.remove() removes the - // delayed BullMQ entry but does NOT fire worker.on('failed'), so - // releaseLocksForFailedJob is never called for the superseded job. - // Manually undo the lock marks from the previous webhook invocation. - if (supersededJobData && supersededJobData.type !== 'github') { - const oldAgentType = supersededJobData.triggerResult?.agentType; - // Use lockKey as a fallback for lock clearing — mirrors the logic at - // Step 8 above so that Sentry alert coalesced jobs (which set lockKey - // but omit workItemId) are properly unlocked on supersede. - const oldLockKey = - supersededJobData.triggerResult?.lockKey ?? - supersededJobData.triggerResult?.workItemId; - if (oldAgentType) { - if (oldLockKey) { - clearWorkItemEnqueued(supersededJobData.projectId, oldLockKey, oldAgentType); - } - clearAgentTypeEnqueued(supersededJobData.projectId, oldAgentType); - clearRecentlyDispatched( - supersededJobData.projectId, - oldAgentType, - supersededJobData.triggerResult?.workItemId, - ); - } - } - } else { - logger.info(`${adapter.type} coalesced dispatch scheduled`, { - agentType: result.agentType, - workItemId: result.workItemId, - projectId: project.id, - coalesceKey: result.coalesceKey, - delayMs: windowMs, - }); - } - } catch (err) { - result.onBlocked?.(); - // Other dispatch-failure paths flow through BullMQ retry → - // `worker.on('failed')` → `releaseLocksForFailedJob` → Sentry - // (per spec 015 plan 1). This catch handles a Redis-side failure - // BEFORE the job is enqueued, so it bypasses that pipeline. Capture - // to Sentry directly under a stable tag so coalesce-scheduling - // failures don't silently escape observability. - captureException(err instanceof Error ? err : new Error(String(err)), { - tags: { source: 'coalesce_schedule_failure' }, - extra: { - projectId: project.id, - workItemId: result.workItemId, - agentType: result.agentType, - coalesceKey: result.coalesceKey, - adapterType: adapter.type, - }, - }); - logger.error(`Failed to schedule coalesced ${adapter.type} job`, { - error: String(err), - coalesceKey: result.coalesceKey, - workItemId: result.workItemId, - }); - return { - shouldProcess: true, - projectId: project.id, - decisionReason: 'Failed to schedule coalesced job to Redis', - }; - } - - // Mark locks for the newly-scheduled job exactly as the non-coalesced - // path does. (The activeExists early-return above ensures we only reach - // this point when a real new job was added to the queue.) - // Use lockKey as a fallback — mirrors Step 8 so Sentry alert coalesced jobs - // (which set lockKey but omit workItemId) get proper lock tracking. - const coalescedLockKey = result.lockKey ?? result.workItemId; - if (coalescedLockKey) { - markWorkItemEnqueued(project.id, coalescedLockKey, result.agentType); - } - markRecentlyDispatched(project.id, result.agentType, result.workItemId); - markAgentTypeEnqueued(project.id, result.agentType); - - return { - shouldProcess: true, - projectId: project.id, - decisionReason: `Coalesced dispatch scheduled: ${result.agentType} agent for work item ${resolveWorkItemLabel(result, event)}`, - }; - } - } - - // GitHub special case: no-agent triggers (pr-merged, pr-ready-to-merge) - // dispatch already performed PM operations — no job queuing needed - if (!result.agentType) { - logger.info('Trigger completed without agent (PM operation done)'); - return { - shouldProcess: true, - projectId: project.id, - decisionReason: 'Trigger completed without agent (PM operation)', - }; - } - - // Step 8: Work-item concurrency lock. - // Use lockKey as a fallback when workItemId is absent. Trigger handlers that - // defer PM card materialisation to the worker (e.g. Sentry issue/metric alerts) - // set lockKey to a stable synthetic key (e.g. `sentry:${issueId}`) so that - // duplicate webhook deliveries are blocked even before the real PM card ID is known. - const effectiveLockKey = result.lockKey ?? result.workItemId; - if (effectiveLockKey) { - const lockStatus = await isWorkItemLocked(project.id, effectiveLockKey, result.agentType); - if (lockStatus.locked) { - result.onBlocked?.(); - logger.info(`Skipping ${adapter.type} job — work item already locked`, { - source: adapter.type, - projectId: project.id, - workItemId: effectiveLockKey, - blockedAgentType: result.agentType, - reason: lockStatus.reason, - }); - // Spec 015/1: distinguish "queued behind a real active dispatch" from - // "lock leaked by a prior dispatch failure". Defaults to awaiting-slot - // on classifier error so a transient infra blip doesn't mis-fire the - // canary. - const classification = await classifyLockState({ - projectId: project.id, - workItemId: effectiveLockKey, - agentType: result.agentType, - }); - const reasonSuffix = lockStatus.reason ?? 'active run exists'; - if (classification === 'wedged') { - // Regression invariant: after spec 015/1 ships, this should never - // fire under normal operation. Capture loudly so any leak is - // observable in production. - captureException( - new Error( - `wedged work-item lock: projectId=${project.id} workItemId=${effectiveLockKey} agentType=${result.agentType}`, - ), - { - tags: { source: 'wedged_lock_canary' }, - extra: { - projectId: project.id, - workItemId: effectiveLockKey, - agentType: result.agentType, - reason: lockStatus.reason, - }, - }, - ); - return { - shouldProcess: true, - projectId: project.id, - decisionReason: `Work item locked (no active dispatch): ${reasonSuffix}`, - }; - } - return { - shouldProcess: true, - projectId: project.id, - decisionReason: `Awaiting worker slot: ${reasonSuffix}`, - }; - } - } - - // Step 8b: Agent-type concurrency limit - let agentTypeMaxConcurrency: number | null = null; - if (result.agentType) { - const concurrencyCheck = await checkAgentTypeConcurrency( - project.id, - result.agentType, - adapter.type, - result.workItemId, - ); - agentTypeMaxConcurrency = concurrencyCheck.maxConcurrency; - if (concurrencyCheck.blocked) { - result.onBlocked?.(); - return { - shouldProcess: true, - projectId: project.id, - decisionReason: 'Agent type concurrency limit reached', - }; - } - } - - try { - // Step 9: Post acknowledgment comment — ack info is now available at build time - // Pass the full triggerResult so PM-focused agents (e.g. backlog-manager) can - // route the ack to the PM tool (Trello/JIRA card) instead of a GitHub PR. - const ackResult = await adapter.postAck(event, payload, project, result.agentType, result); - if (ackResult?.commentId != null) { - logger.info(`${adapter.type} ack comment posted`, { - ackCommentId: ackResult.commentId, - workItemId: event.workItemId, - }); - } else { - logger.debug( - `${adapter.type} ack returned no comment ID (worker will run without pre-seeded comment)`, - { - workItemId: event.workItemId, - }, - ); - } - - // Step 10: Build job with ack info embedded - const job = adapter.buildJob(event, payload, project, result, ackResult); - - // Step 11: Fire optional pre-actions (fire-and-forget) - adapter.firePreActions?.(job, payload); - - // Step 12: Enqueue — job is now durable in Redis - const jobId = await addJob(job); - if (effectiveLockKey) { - markWorkItemEnqueued(project.id, effectiveLockKey, result.agentType); - } - if (result.agentType && agentTypeMaxConcurrency !== null) { - markRecentlyDispatched(project.id, result.agentType, result.workItemId); - markAgentTypeEnqueued(project.id, result.agentType); - } - logger.info(`${adapter.type} job queued`, { - jobId, - eventType: event.eventType, - }); - } catch (err) { - result.onBlocked?.(); - logger.error(`Failed to queue ${adapter.type} job`, { - error: String(err), - eventType: event.eventType, - workItemId: event.workItemId, - }); - return { - shouldProcess: true, - projectId: project.id, - decisionReason: 'Failed to enqueue job to Redis', - }; - } - - return { - shouldProcess: true, - projectId: project.id, - decisionReason: `Job queued: ${result.agentType} agent for work item ${resolveWorkItemLabel(result, event)}`, - }; + return handleTriggerOutcome({ adapter, event, payload, project, result }); } diff --git a/src/router/webhook-trigger-outcomes.ts b/src/router/webhook-trigger-outcomes.ts new file mode 100644 index 000000000..2ad464b5a --- /dev/null +++ b/src/router/webhook-trigger-outcomes.ts @@ -0,0 +1,347 @@ +import { getCoalesceWindowMs } from '../pm/coalesce-config.js'; +import { captureException } from '../sentry.js'; +import type { TriggerResult } from '../types/index.js'; +import { logger } from '../utils/logging.js'; +import { clearAgentTypeEnqueued, clearRecentlyDispatched } from './agent-type-lock.js'; +import type { RouterProjectConfig } from './config.js'; +import type { ParsedWebhookEvent, RouterPlatformAdapter } from './platform-adapter.js'; +import { addJob, scheduleCoalescedJob } from './queue.js'; +import { + checkDispatchLocks, + markCoalescedDispatchEnqueued, + markImmediateDispatchEnqueued, +} from './webhook-dispatch-locks.js'; +import { clearWorkItemEnqueued } from './work-item-lock.js'; + +export interface ProcessRouterWebhookResult { + /** Whether the event was of a processable type for this platform. */ + shouldProcess: boolean; + /** The resolved project identifier, if any. */ + projectId?: string; + /** Human-readable explanation of why the event was processed or skipped. */ + decisionReason?: string; +} + +/** + * Pick the most specific work-item label for a webhook log decisionReason. + * + * Order: result.workItemId > `PR #` > event.workItemId > `(unknown)`. + */ +export function resolveWorkItemLabel(result: TriggerResult, event: ParsedWebhookEvent): string { + if (result.workItemId) return result.workItemId; + if (typeof result.prNumber === 'number') return `PR #${result.prNumber}`; + return event.workItemId ?? '(unknown)'; +} + +export async function handleTriggerOutcome({ + adapter, + event, + payload, + project, + result, +}: { + adapter: RouterPlatformAdapter; + event: ParsedWebhookEvent; + payload: unknown; + project: RouterProjectConfig; + result: TriggerResult; +}): Promise { + if (result.skipReason && result.agentType === null) { + return handleStructuredSkip({ + adapterType: adapter.type, + event, + projectId: project.id, + result, + }); + } + + if (result.deferredRecheck && result.agentType === null) { + return handleDeferredRecheck({ adapter, event, payload, project, result }); + } + + logger.info(`${adapter.type} trigger matched`, { + agentType: result.agentType || '(no agent)', + workItemId: event.workItemId, + projectId: project.id, + }); + + const coalesced = await maybeHandleCoalescedDispatch({ + adapter, + event, + payload, + project, + result, + }); + if (coalesced) return coalesced; + + if (!result.agentType) { + logger.info('Trigger completed without agent (PM operation done)'); + return { + shouldProcess: true, + projectId: project.id, + decisionReason: 'Trigger completed without agent (PM operation)', + }; + } + + return handleImmediateDispatch({ + adapter, + event, + payload, + project, + result: result as TriggerResult & { agentType: string }, + }); +} + +function handleStructuredSkip({ + adapterType, + event, + projectId, + result, +}: { + adapterType: string; + event: ParsedWebhookEvent; + projectId: string; + result: TriggerResult; +}): ProcessRouterWebhookResult { + if (!result.skipReason) { + throw new Error('handleStructuredSkip requires result.skipReason'); + } + logger.info(`${adapterType} trigger self-skipped`, { + handler: result.skipReason.handler, + message: result.skipReason.message, + eventType: event.eventType, + workItemId: event.workItemId, + projectId, + }); + return { + shouldProcess: true, + projectId, + decisionReason: `Trigger ${result.skipReason.handler} skipped: ${result.skipReason.message}`, + }; +} + +async function handleDeferredRecheck({ + adapter, + event, + payload, + project, + result, +}: { + adapter: RouterPlatformAdapter; + event: ParsedWebhookEvent; + payload: unknown; + project: RouterProjectConfig; + result: TriggerResult; +}): Promise { + if (!result.deferredRecheck) { + throw new Error('handleDeferredRecheck requires result.deferredRecheck'); + } + const job = adapter.buildJob(event, payload, project, result, undefined); + try { + await scheduleCoalescedJob( + job, + result.deferredRecheck.coalesceKey, + result.deferredRecheck.delayMs, + ); + logger.info(`${adapter.type} deferred re-check scheduled`, { + coalesceKey: result.deferredRecheck.coalesceKey, + delayMs: result.deferredRecheck.delayMs, + projectId: project.id, + }); + } catch (err) { + captureException(err instanceof Error ? err : new Error(String(err)), { + tags: { source: 'deferred_recheck_schedule_failure' }, + extra: { coalesceKey: result.deferredRecheck.coalesceKey, projectId: project.id }, + }); + logger.error(`Failed to schedule deferred re-check for ${adapter.type} event`, { + error: String(err), + coalesceKey: result.deferredRecheck.coalesceKey, + }); + } + return { + shouldProcess: true, + projectId: project.id, + decisionReason: `Deferred re-check scheduled: ${result.deferredRecheck.coalesceKey}`, + }; +} + +async function maybeHandleCoalescedDispatch({ + adapter, + event, + payload, + project, + result, +}: { + adapter: RouterPlatformAdapter; + event: ParsedWebhookEvent; + payload: unknown; + project: RouterProjectConfig; + result: TriggerResult; +}): Promise { + if (!result.coalesceKey || !result.agentType) return null; + + const windowMs = getCoalesceWindowMs(); + if (windowMs <= 0) return null; + + const job = adapter.buildJob(event, payload, project, result, undefined); + if (job.type === 'trello' || job.type === 'jira' || job.type === 'linear') { + job.pendingAck = true; + job.ackContextHint = result.workItemTitle ?? undefined; + } + + try { + const { superseded, supersededJobData } = await scheduleCoalescedJob( + job, + result.coalesceKey, + windowMs, + ); + + if (superseded) { + logger.info(`${adapter.type} coalesced dispatch superseded prior pending job`, { + agentType: result.agentType, + workItemId: result.workItemId, + projectId: project.id, + coalesceKey: result.coalesceKey, + }); + releaseSupersededJobLocks(supersededJobData); + } else { + logger.info(`${adapter.type} coalesced dispatch scheduled`, { + agentType: result.agentType, + workItemId: result.workItemId, + projectId: project.id, + coalesceKey: result.coalesceKey, + delayMs: windowMs, + }); + } + } catch (err) { + result.onBlocked?.(); + captureException(err instanceof Error ? err : new Error(String(err)), { + tags: { source: 'coalesce_schedule_failure' }, + extra: { + projectId: project.id, + workItemId: result.workItemId, + agentType: result.agentType, + coalesceKey: result.coalesceKey, + adapterType: adapter.type, + }, + }); + logger.error(`Failed to schedule coalesced ${adapter.type} job`, { + error: String(err), + coalesceKey: result.coalesceKey, + workItemId: result.workItemId, + }); + return { + shouldProcess: true, + projectId: project.id, + decisionReason: 'Failed to schedule coalesced job to Redis', + }; + } + + markCoalescedDispatchEnqueued({ + projectId: project.id, + result: result as TriggerResult & { agentType: string }, + }); + + return { + shouldProcess: true, + projectId: project.id, + decisionReason: `Coalesced dispatch scheduled: ${result.agentType} agent for work item ${resolveWorkItemLabel(result, event)}`, + }; +} + +function releaseSupersededJobLocks( + supersededJobData: Awaited>['supersededJobData'], +): void { + if (!supersededJobData || supersededJobData.type === 'github') return; + + const oldAgentType = supersededJobData.triggerResult?.agentType; + const oldLockKey = + supersededJobData.triggerResult?.lockKey ?? supersededJobData.triggerResult?.workItemId; + if (!oldAgentType) return; + + if (oldLockKey) { + clearWorkItemEnqueued(supersededJobData.projectId, oldLockKey, oldAgentType); + } + clearAgentTypeEnqueued(supersededJobData.projectId, oldAgentType); + clearRecentlyDispatched( + supersededJobData.projectId, + oldAgentType, + supersededJobData.triggerResult?.workItemId, + ); +} + +async function handleImmediateDispatch({ + adapter, + event, + payload, + project, + result, +}: { + adapter: RouterPlatformAdapter; + event: ParsedWebhookEvent; + payload: unknown; + project: RouterProjectConfig; + result: TriggerResult & { agentType: string }; +}): Promise { + const lockCheck = await checkDispatchLocks({ + adapterType: adapter.type, + projectId: project.id, + result, + }); + if (lockCheck.blocked) { + return { + shouldProcess: true, + projectId: project.id, + decisionReason: lockCheck.decisionReason, + }; + } + + try { + const ackResult = await adapter.postAck(event, payload, project, result.agentType, result); + if (ackResult?.commentId != null) { + logger.info(`${adapter.type} ack comment posted`, { + ackCommentId: ackResult.commentId, + workItemId: event.workItemId, + }); + } else { + logger.debug( + `${adapter.type} ack returned no comment ID (worker will run without pre-seeded comment)`, + { + workItemId: event.workItemId, + }, + ); + } + + const job = adapter.buildJob(event, payload, project, result, ackResult); + adapter.firePreActions?.(job, payload); + const jobId = await addJob(job); + markImmediateDispatchEnqueued({ + projectId: project.id, + result, + effectiveLockKey: lockCheck.effectiveLockKey, + agentTypeMaxConcurrency: lockCheck.agentTypeMaxConcurrency, + }); + logger.info(`${adapter.type} job queued`, { + jobId, + eventType: event.eventType, + }); + } catch (err) { + result.onBlocked?.(); + logger.error(`Failed to queue ${adapter.type} job`, { + error: String(err), + eventType: event.eventType, + workItemId: event.workItemId, + }); + return { + shouldProcess: true, + projectId: project.id, + decisionReason: 'Failed to enqueue job to Redis', + }; + } + + return { + shouldProcess: true, + projectId: project.id, + decisionReason: `Job queued: ${result.agentType} agent for work item ${resolveWorkItemLabel(result, event)}`, + }; +} From 6be75067b4c7049cb64f799a807fa256f653f533 Mon Sep 17 00:00:00 2001 From: aaight Date: Sat, 9 May 2026 11:42:18 +0200 Subject: [PATCH 06/18] refactor(triggers): extract agent PM summary posting (#1279) Co-authored-by: Cascade Bot --- src/triggers/shared/agent-execution.ts | 84 +---- src/triggers/shared/agent-pm-poster.ts | 4 +- src/triggers/shared/agent-pm-summary.ts | 86 +++++ .../triggers/shared/agent-execution.test.ts | 311 +----------------- .../triggers/shared/agent-pm-poster.test.ts | 10 + .../triggers/shared/agent-pm-summary.test.ts | 229 +++++++++++++ 6 files changed, 344 insertions(+), 380 deletions(-) create mode 100644 src/triggers/shared/agent-pm-summary.ts create mode 100644 tests/unit/triggers/shared/agent-pm-summary.test.ts diff --git a/src/triggers/shared/agent-execution.ts b/src/triggers/shared/agent-execution.ts index d853c6f84..067e3bdca 100644 --- a/src/triggers/shared/agent-execution.ts +++ b/src/triggers/shared/agent-execution.ts @@ -25,11 +25,11 @@ import { validateAgentExecutionLifecycle, } from './agent-execution-lifecycle.js'; import type { AgentExecutionConfig, AgentExecutionContext } from './agent-execution-types.js'; +import { postAgentSummaryToPM } from './agent-pm-summary.js'; import { linkPRPostExecution, persistPreRunWorkItems, prepareAgentWorkItem, - resolveWorkItemId, } from './agent-work-items.js'; import { isPipelineAtCapacity } from './backlog-check.js'; import { triggerDebugAnalysis } from './debug-runner.js'; @@ -127,88 +127,6 @@ async function tryDispatchPostCompletionReview( } } -/** - * Post an agent summary to the PM work item after a successful agent run. - * Cross-source concern: fires for all trigger types (GitHub, Trello, JIRA). - * - * Handles two cases: - * - review agent: structured session state (reviewBody/reviewEvent/reviewUrl) - * - output-based agents (respond-to-ci, respond-to-review, resolve-conflicts): AgentResult.output - */ -async function postAgentSummaryToPM( - agentType: string, - agentResult: AgentResult, - workItemId: string | undefined, - projectId: string, - prNumber: number | undefined, -): Promise { - const { PM_SUMMARY_AGENT_TYPES, isOutputBasedAgent, postReviewToPM, postAgentOutputToPM } = - await import('./agent-pm-poster.js'); - if (!agentResult.success || !PM_SUMMARY_AGENT_TYPES.has(agentType)) return; - - if (isOutputBasedAgent(agentType)) { - // Output-based agents (respond-to-ci, respond-to-review, resolve-conflicts) - // Resolve workItemId: prefer TriggerResult, fall back to DB lookup - const resolvedWorkItemId = await resolveWorkItemId(workItemId, projectId, prNumber); - if (!resolvedWorkItemId) { - logger.warn('Agent PM posting skipped: no workItemId found', { - agentType, - projectId, - prNumber, - }); - return; - } - - logger.info('Posting agent output summary to PM work item', { - agentType, - workItemId: resolvedWorkItemId, - hasProgressCommentId: !!agentResult.progressCommentId, - }); - - await postAgentOutputToPM( - resolvedWorkItemId, - agentType, - agentResult.output, - agentResult.progressCommentId, - ); - } else { - // Review agent: use structured session state - const { getSessionState } = await import('../../gadgets/sessionState.js'); - const sessionState = getSessionState(); - if (!sessionState.reviewBody) { - logger.warn('Review PM posting skipped: no reviewBody in session state'); - return; - } - - // Resolve workItemId only after confirming we have a review to post - const resolvedWorkItemId = await resolveWorkItemId(workItemId, projectId, prNumber); - if (!resolvedWorkItemId) { - logger.warn('Agent PM posting skipped: no workItemId found', { - agentType, - projectId, - prNumber, - }); - return; - } - - logger.info('Posting review summary to PM work item', { - workItemId: resolvedWorkItemId, - hasProgressCommentId: !!agentResult.progressCommentId, - event: sessionState.reviewEvent, - }); - - await postReviewToPM( - resolvedWorkItemId, - { - reviewBody: sessionState.reviewBody, - reviewEvent: sessionState.reviewEvent, - reviewUrl: sessionState.reviewUrl, - }, - agentResult.progressCommentId, - ); - } -} - /** * Shared agent execution pipeline. * diff --git a/src/triggers/shared/agent-pm-poster.ts b/src/triggers/shared/agent-pm-poster.ts index 78fd2cabd..16690daa9 100644 --- a/src/triggers/shared/agent-pm-poster.ts +++ b/src/triggers/shared/agent-pm-poster.ts @@ -3,7 +3,7 @@ * * Handles two cases: * - **Review agent**: structured session state (reviewBody/reviewEvent/reviewUrl) - * - **Output-based agents** (respond-to-ci, respond-to-review, resolve-conflicts): + * - **Output-based agents** (respond-to-ci, respond-to-review, respond-to-pr-comment, resolve-conflicts): * free-form AgentResult.output with per-agent-type formatting * * Best-effort: failures are silently swallowed via safeOperation so they @@ -208,7 +208,7 @@ export async function postReviewToPM( /** * Post agent output to the PM work item as a comment. * - * Used by respond-to-ci, respond-to-review, and resolve-conflicts agents + * Used by respond-to-ci, respond-to-review, respond-to-pr-comment, and resolve-conflicts agents * to replace the progress comment with their final output. * * @param workItemId - The PM work item ID to post to diff --git a/src/triggers/shared/agent-pm-summary.ts b/src/triggers/shared/agent-pm-summary.ts new file mode 100644 index 000000000..faa75c2c7 --- /dev/null +++ b/src/triggers/shared/agent-pm-summary.ts @@ -0,0 +1,86 @@ +import { getSessionState } from '../../gadgets/sessionState.js'; +import type { AgentResult } from '../../types/index.js'; +import { logger } from '../../utils/logging.js'; +import { + isOutputBasedAgent, + PM_SUMMARY_AGENT_TYPES, + postAgentOutputToPM, + postReviewToPM, +} from './agent-pm-poster.js'; +import { resolveWorkItemId } from './agent-work-items.js'; + +/** + * Post an agent summary to the PM work item after a successful agent run. + * Cross-source concern: fires for all trigger types (GitHub, Trello, JIRA). + * + * Handles two cases: + * - review agent: structured session state (reviewBody/reviewEvent/reviewUrl) + * - output-based agents: AgentResult.output with per-agent-type formatting + */ +export async function postAgentSummaryToPM( + agentType: string, + agentResult: AgentResult, + workItemId: string | undefined, + projectId: string, + prNumber: number | undefined, +): Promise { + if (!agentResult.success || !PM_SUMMARY_AGENT_TYPES.has(agentType)) return; + + if (isOutputBasedAgent(agentType)) { + const resolvedWorkItemId = await resolveWorkItemId(workItemId, projectId, prNumber); + if (!resolvedWorkItemId) { + logger.warn('Agent PM posting skipped: no workItemId found', { + agentType, + projectId, + prNumber, + }); + return; + } + + logger.info('Posting agent output summary to PM work item', { + agentType, + workItemId: resolvedWorkItemId, + hasProgressCommentId: !!agentResult.progressCommentId, + }); + + await postAgentOutputToPM( + resolvedWorkItemId, + agentType, + agentResult.output, + agentResult.progressCommentId, + ); + return; + } + + const sessionState = getSessionState(); + if (!sessionState.reviewBody) { + logger.warn('Review PM posting skipped: no reviewBody in session state'); + return; + } + + const resolvedWorkItemId = await resolveWorkItemId(workItemId, projectId, prNumber); + if (!resolvedWorkItemId) { + logger.warn('Agent PM posting skipped: no workItemId found', { + agentType, + projectId, + prNumber, + }); + return; + } + + logger.info('Posting review summary to PM work item', { + workItemId: resolvedWorkItemId, + hasProgressCommentId: !!agentResult.progressCommentId, + event: sessionState.reviewEvent, + }); + + await postReviewToPM( + resolvedWorkItemId, + { + reviewBody: sessionState.reviewBody, + reviewEvent: sessionState.reviewEvent, + reviewUrl: sessionState.reviewUrl, + }, + agentResult.progressCommentId, + ); +} diff --git a/tests/unit/triggers/shared/agent-execution.test.ts b/tests/unit/triggers/shared/agent-execution.test.ts index e37b72468..5414f3f3e 100644 --- a/tests/unit/triggers/shared/agent-execution.test.ts +++ b/tests/unit/triggers/shared/agent-execution.test.ts @@ -19,11 +19,7 @@ const { mockTriggerDebugAnalysis, mockLogger, MockPMLifecycleManager, - mockGetSessionState, - mockPostReviewToPM, - mockPostAgentOutputToPM, - mockPM_SUMMARY_AGENT_TYPES, - mockIsOutputBasedAgent, + mockPostAgentSummaryToPM, mockLookupWorkItemForPR, mockGithubClient, mockParseRepoFullName, @@ -57,21 +53,7 @@ const { handleBudgetWarning: vi.fn().mockResolvedValue(undefined), cleanupProcessing: vi.fn().mockResolvedValue(undefined), })), - mockGetSessionState: vi.fn().mockReturnValue({}), - mockPostReviewToPM: vi.fn().mockResolvedValue(undefined), - mockPostAgentOutputToPM: vi.fn().mockResolvedValue(undefined), - mockPM_SUMMARY_AGENT_TYPES: new Set([ - 'review', - 'respond-to-ci', - 'respond-to-review', - 'resolve-conflicts', - ]), - mockIsOutputBasedAgent: vi - .fn() - .mockImplementation( - (t: string) => - t === 'respond-to-ci' || t === 'respond-to-review' || t === 'resolve-conflicts', - ), + mockPostAgentSummaryToPM: vi.fn().mockResolvedValue(undefined), mockLookupWorkItemForPR: vi.fn().mockResolvedValue(null), mockGithubClient: { getPR: vi.fn().mockResolvedValue({ title: 'feat: test PR', headSha: 'abc123' }), @@ -142,19 +124,8 @@ vi.mock('../../../../src/db/repositories/runsRepository.js', () => ({ updateRunPRNumber: vi.fn().mockResolvedValue(undefined), })); -vi.mock('../../../../src/gadgets/sessionState.js', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - getSessionState: mockGetSessionState, - }; -}); - -vi.mock('../../../../src/triggers/shared/agent-pm-poster.js', () => ({ - postReviewToPM: mockPostReviewToPM, - postAgentOutputToPM: mockPostAgentOutputToPM, - PM_SUMMARY_AGENT_TYPES: mockPM_SUMMARY_AGENT_TYPES, - isOutputBasedAgent: mockIsOutputBasedAgent, +vi.mock('../../../../src/triggers/shared/agent-pm-summary.js', () => ({ + postAgentSummaryToPM: mockPostAgentSummaryToPM, })); vi.mock('../../../../src/github/client.js', () => ({ @@ -264,7 +235,6 @@ describe('runAgentExecutionPipeline facade characterization', () => { mockCheckBudgetExceeded.mockResolvedValue(null); mockHandleAgentResultArtifacts.mockResolvedValue(undefined); mockShouldTriggerDebug.mockResolvedValue(null); - mockGetSessionState.mockReturnValue({}); mockRunAgent.mockResolvedValue({ success: true, output: '', runId: 'run-1' }); mockGithubClient.getCheckSuiteStatus.mockResolvedValue({ allPassing: false }); } @@ -656,283 +626,38 @@ describe('propagateAutoLabelAfterSplitting (via runAgentExecutionPipeline)', () }); }); -// --------------------------------------------------------------------------- -// postAgentSummaryToPM (via runAgentExecutionPipeline) -// --------------------------------------------------------------------------- - -describe('postAgentSummaryToPM (via runAgentExecutionPipeline)', () => { - function setupReviewDefaults() { +describe('agent PM summary facade delegation', () => { + beforeEach(() => { mockCreatePMProvider.mockReturnValue({}); mockResolveProjectPMConfig.mockReturnValue(PM_CONFIG); mockValidateIntegrations.mockResolvedValue({ valid: true, errors: [] }); mockCheckBudgetExceeded.mockResolvedValue(null); mockHandleAgentResultArtifacts.mockResolvedValue(undefined); mockShouldTriggerDebug.mockResolvedValue(null); - } - - beforeEach(() => { - setupReviewDefaults(); - }); - - it('calls postReviewToPM when agentType=review, success, and sessionState has reviewBody', async () => { - mockRunAgent.mockResolvedValueOnce({ - success: true, - output: '', - runId: 'run-rev', - progressCommentId: 'pm-comment-1', - }); - mockGetSessionState.mockReturnValue({ - reviewBody: 'Looks good', - reviewEvent: 'APPROVE', - reviewUrl: 'https://github.com/acme/myapp/pull/42#pullrequestreview-1', - }); - - await runAgentExecutionPipeline( - { agentType: 'review', agentInput: {}, workItemId: 'card-1', prNumber: 42 }, - PROJECT, - CONFIG, - ); - - expect(mockPostReviewToPM).toHaveBeenCalledWith( - 'card-1', - expect.objectContaining({ reviewBody: 'Looks good' }), - 'pm-comment-1', - ); - }); - - it('skips PM posting entirely for non-summary agent types (implementation)', async () => { - mockRunAgent.mockResolvedValueOnce({ success: true, output: '', runId: 'run-impl' }); - mockGetSessionState.mockReturnValue({ reviewBody: 'something' }); - - await runAgentExecutionPipeline( - { agentType: 'implementation', agentInput: {}, workItemId: 'card-1' }, - PROJECT, - CONFIG, - ); - - expect(mockPostReviewToPM).not.toHaveBeenCalled(); - expect(mockPostAgentOutputToPM).not.toHaveBeenCalled(); - }); - - it('skips when agent failed', async () => { - mockRunAgent.mockResolvedValueOnce({ success: false, output: '', error: 'review error' }); - mockGetSessionState.mockReturnValue({ reviewBody: 'Looks good' }); - - await runAgentExecutionPipeline( - { agentType: 'review', agentInput: {}, workItemId: 'card-1' }, - PROJECT, - CONFIG, - ); - - expect(mockPostReviewToPM).not.toHaveBeenCalled(); - }); - - it('skips when sessionState has no reviewBody and logs reason', async () => { - mockRunAgent.mockResolvedValueOnce({ success: true, output: '', runId: 'run-rev' }); - mockGetSessionState.mockReturnValue({ reviewBody: null }); - - await runAgentExecutionPipeline( - { agentType: 'review', agentInput: {}, workItemId: 'card-1' }, - PROJECT, - CONFIG, - ); - - expect(mockPostReviewToPM).not.toHaveBeenCalled(); - expect(mockLogger.warn).toHaveBeenCalledWith( - 'Review PM posting skipped: no reviewBody in session state', - ); - }); - - it('resolves workItemId from DB when result.workItemId is undefined', async () => { - mockRunAgent.mockResolvedValueOnce({ success: true, output: '', runId: 'run-rev' }); - mockGetSessionState.mockReturnValue({ - reviewBody: 'Nice', - reviewEvent: 'COMMENT', - reviewUrl: 'https://github.com/acme/myapp/pull/99#pullrequestreview-5', - }); - mockLookupWorkItemForPR.mockResolvedValueOnce('card-from-db'); - - await runAgentExecutionPipeline( - { agentType: 'review', agentInput: {}, prNumber: 99 }, - PROJECT, - CONFIG, - ); - - expect(mockLookupWorkItemForPR).toHaveBeenCalledWith('project-1', 99); - expect(mockPostReviewToPM).toHaveBeenCalledWith( - 'card-from-db', - expect.objectContaining({ reviewBody: 'Nice' }), - undefined, - ); - }); - - it('skips when no workItemId found (neither result nor DB) and logs reason', async () => { - mockRunAgent.mockResolvedValueOnce({ success: true, output: '', runId: 'run-rev' }); - mockGetSessionState.mockReturnValue({ - reviewBody: 'Good', - reviewEvent: 'APPROVE', - reviewUrl: 'https://github.com/acme/myapp/pull/55#pullrequestreview-6', - }); - mockLookupWorkItemForPR.mockResolvedValueOnce(null); - - await runAgentExecutionPipeline( - { agentType: 'review', agentInput: {}, prNumber: 55 }, - PROJECT, - CONFIG, - ); - - expect(mockPostReviewToPM).not.toHaveBeenCalled(); - expect(mockLogger.warn).toHaveBeenCalledWith( - 'Agent PM posting skipped: no workItemId found', - expect.objectContaining({ agentType: 'review', projectId: 'project-1', prNumber: 55 }), - ); - }); - - it('calls postAgentOutputToPM for respond-to-ci with successful result and non-empty output', async () => { - mockRunAgent.mockResolvedValueOnce({ - success: true, - output: 'Fixed CI by updating the build config.', - runId: 'run-ci', - progressCommentId: 'pm-prog-ci', - }); - - await runAgentExecutionPipeline( - { agentType: 'respond-to-ci', agentInput: {}, workItemId: 'card-2', prNumber: 10 }, - PROJECT, - CONFIG, - ); - - expect(mockPostAgentOutputToPM).toHaveBeenCalledWith( - 'card-2', - 'respond-to-ci', - 'Fixed CI by updating the build config.', - 'pm-prog-ci', - ); - expect(mockPostReviewToPM).not.toHaveBeenCalled(); }); - it('calls postAgentOutputToPM for respond-to-review with successful result', async () => { - mockRunAgent.mockResolvedValueOnce({ + it('delegates PM summary posting after the agent run', async () => { + const agentResult = { success: true, output: 'Addressed all review comments.', runId: 'run-rr', progressCommentId: 'pm-prog-rr', - }); + }; + mockRunAgent.mockResolvedValueOnce(agentResult); await runAgentExecutionPipeline( - { agentType: 'respond-to-review', agentInput: {}, workItemId: 'card-3' }, + { agentType: 'respond-to-review', agentInput: {}, workItemId: 'card-3', prNumber: 42 }, PROJECT, CONFIG, ); - expect(mockPostAgentOutputToPM).toHaveBeenCalledWith( - 'card-3', + expect(mockPostAgentSummaryToPM).toHaveBeenCalledWith( 'respond-to-review', - 'Addressed all review comments.', - 'pm-prog-rr', - ); - }); - - it('calls postAgentOutputToPM for resolve-conflicts with successful result', async () => { - mockRunAgent.mockResolvedValueOnce({ - success: true, - output: 'Resolved merge conflicts in 3 files.', - runId: 'run-rc', - }); - - await runAgentExecutionPipeline( - { agentType: 'resolve-conflicts', agentInput: {}, workItemId: 'card-4' }, - PROJECT, - CONFIG, - ); - - expect(mockPostAgentOutputToPM).toHaveBeenCalledWith( - 'card-4', - 'resolve-conflicts', - 'Resolved merge conflicts in 3 files.', - undefined, - ); - }); - - it('delegates empty output to postAgentOutputToPM (which handles the guard)', async () => { - mockRunAgent.mockResolvedValueOnce({ - success: true, - output: '', - runId: 'run-ci-empty', - }); - - await runAgentExecutionPipeline( - { agentType: 'respond-to-ci', agentInput: {}, workItemId: 'card-5' }, - PROJECT, - CONFIG, - ); - - // The pipeline calls postAgentOutputToPM — the empty-output guard lives there, not here - expect(mockPostAgentOutputToPM).toHaveBeenCalledWith('card-5', 'respond-to-ci', '', undefined); - }); - - it('does not call postAgentOutputToPM when agent failed', async () => { - mockRunAgent.mockResolvedValueOnce({ - success: false, - output: 'Some output before failure.', - error: 'CI fix failed', - }); - - await runAgentExecutionPipeline( - { agentType: 'respond-to-ci', agentInput: {}, workItemId: 'card-6' }, - PROJECT, - CONFIG, - ); - - expect(mockPostAgentOutputToPM).not.toHaveBeenCalled(); - expect(mockPostReviewToPM).not.toHaveBeenCalled(); - }); - - it('does not post to PM for splitting agent type', async () => { - // Extra mock setup needed because splitting's success path triggers - // propagateAutoLabelAfterSplitting, which calls getPMProvider(). - mockCreatePMProvider.mockReturnValue({}); - mockResolveProjectPMConfig.mockReturnValue(PM_CONFIG); - mockGetPMProvider.mockReturnValue( - mockProvider({ listWorkItems: vi.fn().mockResolvedValue([]) }), - ); - mockHasAutoLabel.mockReturnValue(false); - mockRunAgent.mockResolvedValueOnce({ - success: true, - output: 'Split card into 3 sub-cards.', - runId: 'run-split', - }); - - await runAgentExecutionPipeline( - { agentType: 'splitting', agentInput: {}, workItemId: 'card-8' }, - PROJECT, - CONFIG, - ); - - expect(mockPostReviewToPM).not.toHaveBeenCalled(); - expect(mockPostAgentOutputToPM).not.toHaveBeenCalled(); - }); - - it('passes progressCommentId through', async () => { - mockRunAgent.mockResolvedValueOnce({ - success: true, - output: '', - runId: 'run-rev', - progressCommentId: 'pm-prog-xyz', - }); - mockGetSessionState.mockReturnValue({ - reviewBody: 'All good', - reviewEvent: 'APPROVE', - reviewUrl: 'https://github.com/acme/myapp/pull/42#pullrequestreview-7', - }); - - await runAgentExecutionPipeline( - { agentType: 'review', agentInput: {}, workItemId: 'card-1', prNumber: 42 }, - PROJECT, - CONFIG, + agentResult, + 'card-3', + 'project-1', + 42, ); - - expect(mockPostReviewToPM).toHaveBeenCalledWith('card-1', expect.anything(), 'pm-prog-xyz'); }); }); @@ -948,7 +673,6 @@ describe('linkPRPostExecution PR title backfill (via runAgentExecutionPipeline)' mockCheckBudgetExceeded.mockResolvedValue(null); mockHandleAgentResultArtifacts.mockResolvedValue(undefined); mockShouldTriggerDebug.mockResolvedValue(null); - mockGetSessionState.mockReturnValue({}); mockParseRepoFullName.mockReturnValue({ owner: 'acme', repo: 'myapp' }); }); @@ -1018,7 +742,6 @@ describe('pre-execution PR linking (via runAgentExecutionPipeline)', () => { mockCheckBudgetExceeded.mockResolvedValue(null); mockHandleAgentResultArtifacts.mockResolvedValue(undefined); mockShouldTriggerDebug.mockResolvedValue(null); - mockGetSessionState.mockReturnValue({}); mockRunAgent.mockResolvedValue({ success: true, output: '', runId: 'run-1' }); }); @@ -1126,7 +849,6 @@ describe('workItemId staleness recovery (via runAgentExecutionPipeline)', () => mockCheckBudgetExceeded.mockResolvedValue(null); mockHandleAgentResultArtifacts.mockResolvedValue(undefined); mockShouldTriggerDebug.mockResolvedValue(null); - mockGetSessionState.mockReturnValue({}); mockRunAgent.mockResolvedValue({ success: true, output: '', runId: 'run-1' }); }); @@ -1220,7 +942,6 @@ describe('post-completion review dispatch (via runAgentExecutionPipeline)', () = mockCheckBudgetExceeded.mockResolvedValue(null); mockHandleAgentResultArtifacts.mockResolvedValue(undefined); mockShouldTriggerDebug.mockResolvedValue(null); - mockGetSessionState.mockReturnValue({}); mockParseRepoFullName.mockReturnValue({ owner: 'acme', repo: 'myapp' }); mockGithubClient.getPR.mockResolvedValue({ title: 'feat: test PR', diff --git a/tests/unit/triggers/shared/agent-pm-poster.test.ts b/tests/unit/triggers/shared/agent-pm-poster.test.ts index cf2de8ec5..85d155fc0 100644 --- a/tests/unit/triggers/shared/agent-pm-poster.test.ts +++ b/tests/unit/triggers/shared/agent-pm-poster.test.ts @@ -45,6 +45,7 @@ describe('PM_SUMMARY_AGENT_TYPES and isOutputBasedAgent', () => { expect(PM_SUMMARY_AGENT_TYPES).toContain('review'); expect(PM_SUMMARY_AGENT_TYPES).toContain('respond-to-ci'); expect(PM_SUMMARY_AGENT_TYPES).toContain('respond-to-review'); + expect(PM_SUMMARY_AGENT_TYPES).toContain('respond-to-pr-comment'); expect(PM_SUMMARY_AGENT_TYPES).toContain('resolve-conflicts'); }); @@ -56,6 +57,7 @@ describe('PM_SUMMARY_AGENT_TYPES and isOutputBasedAgent', () => { it('isOutputBasedAgent returns true for output-based agents', () => { expect(isOutputBasedAgent('respond-to-ci')).toBe(true); expect(isOutputBasedAgent('respond-to-review')).toBe(true); + expect(isOutputBasedAgent('respond-to-pr-comment')).toBe(true); expect(isOutputBasedAgent('resolve-conflicts')).toBe(true); }); @@ -162,6 +164,14 @@ describe('formatAgentOutputForPM', () => { expect(result).toContain('Resolved merge conflicts in 3 files.'); }); + it('formats respond-to-pr-comment output with correct emoji and header', () => { + const result = formatAgentOutputForPM('respond-to-pr-comment', 'Answered the PR question.'); + + expect(result).toContain('📝'); + expect(result).toContain('**PR Comment Response**'); + expect(result).toContain('Answered the PR question.'); + }); + it('tail-extracts when output exceeds 2000 chars', () => { // Build output where the first part is clearly distinguishable from the tail const uniquePrefix = 'UNIQUE_START_MARKER\n'; diff --git a/tests/unit/triggers/shared/agent-pm-summary.test.ts b/tests/unit/triggers/shared/agent-pm-summary.test.ts new file mode 100644 index 000000000..bf566c4e9 --- /dev/null +++ b/tests/unit/triggers/shared/agent-pm-summary.test.ts @@ -0,0 +1,229 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockGetSessionState, + mockPostReviewToPM, + mockPostAgentOutputToPM, + mockPM_SUMMARY_AGENT_TYPES, + mockIsOutputBasedAgent, + mockLookupWorkItemForPR, + mockLogger, +} = vi.hoisted(() => ({ + mockGetSessionState: vi.fn().mockReturnValue({}), + mockPostReviewToPM: vi.fn().mockResolvedValue(undefined), + mockPostAgentOutputToPM: vi.fn().mockResolvedValue(undefined), + mockPM_SUMMARY_AGENT_TYPES: new Set([ + 'review', + 'respond-to-ci', + 'respond-to-review', + 'respond-to-pr-comment', + 'resolve-conflicts', + ]), + mockIsOutputBasedAgent: vi + .fn() + .mockImplementation((agentType: string) => + ['respond-to-ci', 'respond-to-review', 'respond-to-pr-comment', 'resolve-conflicts'].includes( + agentType, + ), + ), + mockLookupWorkItemForPR: vi.fn().mockResolvedValue(null), + mockLogger: { + info: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('../../../../src/gadgets/sessionState.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getSessionState: mockGetSessionState, + }; +}); + +vi.mock('../../../../src/triggers/shared/agent-pm-poster.js', () => ({ + postReviewToPM: mockPostReviewToPM, + postAgentOutputToPM: mockPostAgentOutputToPM, + PM_SUMMARY_AGENT_TYPES: mockPM_SUMMARY_AGENT_TYPES, + isOutputBasedAgent: mockIsOutputBasedAgent, +})); + +vi.mock('../../../../src/db/repositories/prWorkItemsRepository.js', () => ({ + lookupWorkItemForPR: mockLookupWorkItemForPR, +})); + +vi.mock('../../../../src/utils/logging.js', () => ({ + logger: mockLogger, +})); + +import { postAgentSummaryToPM } from '../../../../src/triggers/shared/agent-pm-summary.js'; + +describe('postAgentSummaryToPM', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetSessionState.mockReturnValue({}); + mockLookupWorkItemForPR.mockResolvedValue(null); + }); + + it('calls postReviewToPM when agentType=review, success, and sessionState has reviewBody', async () => { + mockGetSessionState.mockReturnValue({ + reviewBody: 'Looks good', + reviewEvent: 'APPROVE', + reviewUrl: 'https://github.com/acme/myapp/pull/42#pullrequestreview-1', + }); + + await postAgentSummaryToPM( + 'review', + { success: true, output: '', runId: 'run-rev', progressCommentId: 'pm-comment-1' }, + 'card-1', + 'project-1', + 42, + ); + + expect(mockPostReviewToPM).toHaveBeenCalledWith( + 'card-1', + expect.objectContaining({ reviewBody: 'Looks good' }), + 'pm-comment-1', + ); + }); + + it('skips PM posting entirely for non-summary agent types', async () => { + mockGetSessionState.mockReturnValue({ reviewBody: 'something' }); + + await postAgentSummaryToPM( + 'implementation', + { success: true, output: '', runId: 'run-impl' }, + 'card-1', + 'project-1', + undefined, + ); + + expect(mockPostReviewToPM).not.toHaveBeenCalled(); + expect(mockPostAgentOutputToPM).not.toHaveBeenCalled(); + expect(mockGetSessionState).not.toHaveBeenCalled(); + }); + + it('skips when agent failed', async () => { + mockGetSessionState.mockReturnValue({ reviewBody: 'Looks good' }); + + await postAgentSummaryToPM( + 'review', + { success: false, output: '', error: 'review error' }, + 'card-1', + 'project-1', + undefined, + ); + + expect(mockPostReviewToPM).not.toHaveBeenCalled(); + expect(mockGetSessionState).not.toHaveBeenCalled(); + }); + + it('skips when sessionState has no reviewBody and logs reason', async () => { + mockGetSessionState.mockReturnValue({ reviewBody: null }); + + await postAgentSummaryToPM( + 'review', + { success: true, output: '', runId: 'run-rev' }, + 'card-1', + 'project-1', + undefined, + ); + + expect(mockPostReviewToPM).not.toHaveBeenCalled(); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Review PM posting skipped: no reviewBody in session state', + ); + }); + + it('resolves workItemId from DB when result.workItemId is undefined', async () => { + mockGetSessionState.mockReturnValue({ + reviewBody: 'Nice', + reviewEvent: 'COMMENT', + reviewUrl: 'https://github.com/acme/myapp/pull/99#pullrequestreview-5', + }); + mockLookupWorkItemForPR.mockResolvedValueOnce('card-from-db'); + + await postAgentSummaryToPM( + 'review', + { success: true, output: '', runId: 'run-rev' }, + undefined, + 'project-1', + 99, + ); + + expect(mockLookupWorkItemForPR).toHaveBeenCalledWith('project-1', 99); + expect(mockPostReviewToPM).toHaveBeenCalledWith( + 'card-from-db', + expect.objectContaining({ reviewBody: 'Nice' }), + undefined, + ); + }); + + it('skips when no workItemId found and logs reason', async () => { + mockGetSessionState.mockReturnValue({ + reviewBody: 'Good', + reviewEvent: 'APPROVE', + reviewUrl: 'https://github.com/acme/myapp/pull/55#pullrequestreview-6', + }); + mockLookupWorkItemForPR.mockResolvedValueOnce(null); + + await postAgentSummaryToPM( + 'review', + { success: true, output: '', runId: 'run-rev' }, + undefined, + 'project-1', + 55, + ); + + expect(mockPostReviewToPM).not.toHaveBeenCalled(); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Agent PM posting skipped: no workItemId found', + expect.objectContaining({ agentType: 'review', projectId: 'project-1', prNumber: 55 }), + ); + }); + + it.each([ + ['respond-to-ci', 'Fixed CI by updating the build config.'], + ['respond-to-review', 'Addressed all review comments.'], + ['respond-to-pr-comment', 'Answered the PR comment.'], + ['resolve-conflicts', 'Resolved merge conflicts in 3 files.'], + ])('calls postAgentOutputToPM for %s with successful result', async (agentType, output) => { + await postAgentSummaryToPM( + agentType, + { success: true, output, runId: 'run-output', progressCommentId: 'pm-prog' }, + 'card-2', + 'project-1', + 10, + ); + + expect(mockPostAgentOutputToPM).toHaveBeenCalledWith('card-2', agentType, output, 'pm-prog'); + expect(mockPostReviewToPM).not.toHaveBeenCalled(); + }); + + it('delegates empty output to postAgentOutputToPM', async () => { + await postAgentSummaryToPM( + 'respond-to-ci', + { success: true, output: '', runId: 'run-ci-empty' }, + 'card-5', + 'project-1', + undefined, + ); + + expect(mockPostAgentOutputToPM).toHaveBeenCalledWith('card-5', 'respond-to-ci', '', undefined); + }); + + it('does not call postAgentOutputToPM when agent failed', async () => { + await postAgentSummaryToPM( + 'respond-to-ci', + { success: false, output: 'Some output before failure.', error: 'CI fix failed' }, + 'card-6', + 'project-1', + undefined, + ); + + expect(mockPostAgentOutputToPM).not.toHaveBeenCalled(); + expect(mockPostReviewToPM).not.toHaveBeenCalled(); + }); +}); From 2566760f315f31669c0a5229f6de607307b34786 Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Sat, 9 May 2026 10:15:48 +0000 Subject: [PATCH 07/18] fix(cascade-tools): accept --boolFlag value + wrap oclif parse errors in envelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prod 2026-05-09: 9/14 codex runs that called pm read-work-item hit `--includeComments true` with `exit=2` and empty stdout — silent failures that triggered help-read storms and wasted iterations. Two coupled regressions against spec 014: 1. nativeToolPrompts.formatParam emitted `# example: --includeComments 'true'` for boolean flags whose example was a literal `true|false`. Oclif's `Flags.boolean({ allowNo: true })` rejects that form — the prompt taught a syntax the CLI rejected ("no prompt lying" rule violation). 2. Oclif's `FailedFlagValidationError`, `FlagInvalidOptionError`, and `UnexpectedArgsError` escaped the existing unknown-flag catch in cliCommandFactory. Parse-time failures bypassed emitCliError, so the agent got exit 2 with no envelope to self-correct from. Fixes: - formatParam now renders boolean examples as the canonical toggle (`--key` or `--no-key`), never `--key 'true'`. - cliCommandFactory.execute pre-processes argv so boolean flags accept `--key true|false|yes|no|1|0` (space- or equals-separated, case-insensitive), rewriting them into oclif's canonical toggle. Malformed values surface inline through emitCliError as a `flag-parse` envelope with `got`, `expected`, and `hint` populated. - classifyParseError wraps the three oclif parse-time error classes into structured envelopes (missing-required, enum-mismatch, flag-parse fallback). - New static guard at tests/unit/gadgets/shared/promptExamplesGrammar.test.ts iterates all 23 production gadget definitions and asserts the rendered prompt never contains `-- 'true'` / `'false'` for any boolean param. Verified to fail loudly when the renderer fix is reverted. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/backends/shared/nativeToolPrompts.ts | 21 +- src/gadgets/shared/cliCommandFactory.ts | 208 +++++++++++++++++- .../backends/shared-nativeToolPrompts.test.ts | 30 +++ tests/unit/cli/cli-command-factory.test.ts | 188 ++++++++++++++++ .../shared/promptExamplesGrammar.test.ts | 86 ++++++++ 5 files changed, 526 insertions(+), 7 deletions(-) create mode 100644 tests/unit/gadgets/shared/promptExamplesGrammar.test.ts diff --git a/src/backends/shared/nativeToolPrompts.ts b/src/backends/shared/nativeToolPrompts.ts index 61db1d3d6..0fd4fe7f8 100644 --- a/src/backends/shared/nativeToolPrompts.ts +++ b/src/backends/shared/nativeToolPrompts.ts @@ -67,12 +67,23 @@ function formatParam( // Spec 014: surface a concrete shape beneath the flag so agents see a // copy/paste-ready JSON payload without having to run --help. + // + // Booleans are special: oclif's `Flags.boolean({ allowNo: true })` rejects + // `--key 'true'` at parse time, so the example must mirror the canonical + // CLI grammar (`--key` / `--no-key`) — never a quoted value. Prod regression + // (2026-05-09): 9/14 codex runs hit `--includeComments true` because the + // per-flag example said exactly that. The synopsis renders the toggle form; + // the example reinforces it concretely. if (schema.example !== undefined) { - try { - result += `\n # example: --${key} '${JSON.stringify(schema.example)}'`; - } catch { - // JSON.stringify throws on cyclic refs — never in our tool definitions, - // but be defensive so a malformed example never crashes prompt building. + if (schema.type === 'boolean') { + result += schema.example ? `\n # example: --${key}` : `\n # example: --no-${key}`; + } else { + try { + result += `\n # example: --${key} '${JSON.stringify(schema.example)}'`; + } catch { + // JSON.stringify throws on cyclic refs — never in our tool definitions, + // but be defensive so a malformed example never crashes prompt building. + } } } diff --git a/src/gadgets/shared/cliCommandFactory.ts b/src/gadgets/shared/cliCommandFactory.ts index 6d52636ab..ca14d506f 100644 --- a/src/gadgets/shared/cliCommandFactory.ts +++ b/src/gadgets/shared/cliCommandFactory.ts @@ -15,7 +15,7 @@ import { Flags } from '@oclif/core'; import { distance } from 'fastest-levenshtein'; import { CredentialScopedCommand, resolveOwnerRepo } from '../../cli/base.js'; -import { emitCliError } from './errorEnvelope.js'; +import { type EmitCliErrorOptions, emitCliError } from './errorEnvelope.js'; import type { CLIAutoResolved, FileInputAlternative, @@ -246,6 +246,193 @@ function isNonexistentFlagError(err: unknown): err is { flags: string[]; message return looksLikeCLIParse && Array.isArray(e.flags); } +/** + * Spec 014, prod regression 2026-05-09: oclif's parse-time errors (missing + * required, enum mismatch, unexpected positional from a boolean-value miss) + * historically threw past the existing unknown-flag catch with `exit code 2` + * and empty stdout — bypassing the structured envelope contract. Classify the + * error here so every parse failure reaches the agent through the same shape. + * + * Returns a ready-to-emit envelope (omitting only the sink fields). Returns + * `null` when the error doesn't match any oclif parse-time shape — caller + * re-throws so unexpected exceptions still surface. + */ +function classifyParseError( + err: unknown, +): Omit | null { + if (!err || typeof err !== 'object') return null; + const e = err as { name?: string; constructor?: { name?: string }; message?: string }; + const ctorName = e.constructor?.name ?? ''; + const message = typeof e.message === 'string' ? e.message : ''; + + // FailedFlagValidationError → "Missing required flag " + if (ctorName === 'FailedFlagValidationError') { + const m = message.match(/Missing required flag\s+([\w-]+)/); + if (m) { + return { + type: 'missing-required', + flag: m[1], + message: `Missing required flag --${m[1]}`, + hint: `pass --${m[1]} (see --help for the full signature)`, + }; + } + } + + // FlagInvalidOptionError → "Expected --= to be one of: " + if (ctorName === 'FlagInvalidOptionError') { + const m = message.match(/Expected --([\w-]+)=(\S+) to be one of:\s+(.+?)(?:\n|$)/); + if (m) { + return { + type: 'enum-mismatch', + flag: m[1], + got: m[2], + expected: m[3].trim(), + message: `Flag --${m[1]} got '${m[2]}'; expected one of: ${m[3].trim()}`, + }; + } + } + + // UnexpectedArgsError → fallback for boolean-value-form misses that escape + // the preprocessor (e.g. boolean toggle followed by a non-flag token we + // chose not to consume because it didn't look bool-shaped). + if (ctorName === 'UnexpectedArgsError') { + const m = message.match(/Unexpected argument:\s+(.+?)(?:\n|$)/); + if (m) { + return { + type: 'flag-parse', + got: m[1].trim(), + message, + }; + } + } + + // Generic CLIParseError fallback (rare). + if (ctorName.endsWith('Error') && /flag|argument|parse/i.test(message)) { + return { type: 'flag-parse', message }; + } + return null; +} + +/** + * Recognised string forms accepted as a value for boolean flags. Codex agents + * reach for `--includeComments true` (the dominant 2026-05-09 prod failure) + * even when the synopsis says `--[no-]includeComments`; widen the parser so + * both shapes work, then keep oclif's strict toggle semantics for everything + * else. Returns `true` / `false` for recognised values, `null` otherwise so + * the caller can treat the original token as a non-bool value. + */ +function normalizeBoolValue(raw: string): boolean | null { + const lc = raw.toLowerCase(); + if (lc === 'true' || lc === 'yes' || lc === '1') return true; + if (lc === 'false' || lc === 'no' || lc === '0') return false; + return null; +} + +/** + * Pre-process argv so boolean flags accept the natural value form. Each + * `--key true|false|...` (space- or equals-separated) is rewritten to oclif's + * canonical toggle (`--key` or `--no-key`); malformed values surface as a + * structured `flag-parse` envelope before oclif sees the argv. + * + * The preprocessor never consumes a token that LOOKS like another flag + * (starts with `--`) — that token belongs to a different flag, not to the + * preceding boolean. Bare-toggle invocations stay untouched. + */ +function massageBooleanFlagValues( + argv: readonly string[] | undefined, + booleanFlags: ReadonlySet, + sink: ErrorSink, +): string[] | undefined { + // Pass through `undefined` so oclif's `parse(Cmd)` (no argv arg) keeps + // working — some tests construct commands without seeded argv. + if (argv === undefined) return undefined; + if (booleanFlags.size === 0) return [...argv]; + const result: string[] = []; + for (let i = 0; i < argv.length; i++) { + const tok = argv[i]; + + // --flag=value form + if (tok.startsWith('--') && tok.includes('=')) { + const eqIdx = tok.indexOf('='); + const name = tok.slice(2, eqIdx); + if (booleanFlags.has(name)) { + const value = tok.slice(eqIdx + 1); + const normalized = normalizeBoolValue(value); + if (normalized === true) { + result.push(`--${name}`); + continue; + } + if (normalized === false) { + result.push(`--no-${name}`); + continue; + } + emitCliError({ + type: 'flag-parse', + flag: name, + message: `Boolean flag --${name} got value '${value}'; accepts true|false|yes|no|1|0`, + got: value, + expected: 'true|false|yes|no|1|0', + hint: `Use --${name} or --no-${name} for the canonical toggle form, or --${name}=true / --${name}=false.`, + stdout: sink.stdout, + stderr: sink.stderr, + exit: sink.exit, + }); + } + } + + // --flag form + if (tok.startsWith('--') && !tok.includes('=')) { + const name = tok.slice(2); + if (booleanFlags.has(name) && i + 1 < argv.length) { + const next = argv[i + 1]; + const normalized = normalizeBoolValue(next); + if (normalized === true) { + result.push(`--${name}`); + i++; + continue; + } + if (normalized === false) { + result.push(`--no-${name}`); + i++; + continue; + } + // Next token is something else. If it doesn't start with `--`, the + // agent meant it as a value to this boolean — surface a precise + // envelope here so we don't bottom out as `Unexpected argument`. + if (!next.startsWith('--')) { + emitCliError({ + type: 'flag-parse', + flag: name, + message: `Boolean flag --${name} got value '${next}'; accepts true|false|yes|no|1|0`, + got: next, + expected: 'true|false|yes|no|1|0', + hint: `Use --${name} or --no-${name} for the canonical toggle form.`, + stdout: sink.stdout, + stderr: sink.stderr, + exit: sink.exit, + }); + } + // next is another flag — leave the bare toggle as-is. + } + } + result.push(tok); + } + return result; +} + +/** + * Collect the set of boolean flag names declared by a tool definition (used by + * the argv preprocessor to know which flags accept the value form). + */ +function collectBooleanFlagNames(def: ToolDefinition): Set { + const names = new Set(); + for (const [name, paramDef] of Object.entries(def.parameters)) { + if (paramDef.gadgetOnly) continue; + if (paramDef.type === 'boolean') names.add(name); + } + return names; +} + /** * Build an error sink bound to a CredentialScopedCommand instance, so that * emitCliError routes envelope output through `instance.log` (stripping the @@ -621,6 +808,7 @@ export function createCLICommand( const commandPrefix = deriveCommandPrefix(def.name); const staticExamples = buildOclifExamples(def, commandPrefix); + const booleanFlagNames = collectBooleanFlagNames(def); class FactoryCommand extends CredentialScopedCommand { static override description = def.description; @@ -632,9 +820,15 @@ export function createCLICommand( // log/exit — lets tests spy on instance.log and instance.exit. const sink = buildSink(this); + // Pre-process argv so boolean flags accept the natural value form + // (`--key true|false|yes|no|1|0`). Reshapes to oclif's canonical + // toggle (`--key` / `--no-key`) before parsing; emits a structured + // flag-parse envelope inline for malformed bool values. + const massagedArgv = massageBooleanFlagValues(this.argv, booleanFlagNames, sink); + let flags: unknown; try { - ({ flags } = await this.parse(FactoryCommand)); + ({ flags } = await this.parse(FactoryCommand, massagedArgv)); } catch (err) { if (isNonexistentFlagError(err)) { const candidates = collectCandidateFlags(def); @@ -651,6 +845,16 @@ export function createCLICommand( }); return; } + const classified = classifyParseError(err); + if (classified) { + emitCliError({ + ...classified, + stdout: sink.stdout, + stderr: sink.stderr, + exit: sink.exit, + }); + return; + } throw err; } const parsedFlags = flags as ParsedFlags; diff --git a/tests/unit/backends/shared-nativeToolPrompts.test.ts b/tests/unit/backends/shared-nativeToolPrompts.test.ts index 712a05c34..83db9991e 100644 --- a/tests/unit/backends/shared-nativeToolPrompts.test.ts +++ b/tests/unit/backends/shared-nativeToolPrompts.test.ts @@ -262,6 +262,36 @@ describe('buildToolGuidance', () => { ]); expect(result).toContain('[--verbose]'); }); + + // Prod regression — 9/14 codex runs hit `--includeComments true` because + // the example renderer emitted `# example: --includeComments 'true'` while + // oclif's `Flags.boolean({ allowNo: true })` rejects that form. The + // example must match the canonical CLI grammar — `--key` for true, + // `--no-key` for false — never `--key 'true'`. + it('renders example=true as the toggle form (--key) — never as a quoted value', () => { + const result = buildToolGuidance([ + makeManifest({ + parameters: { + includeComments: { type: 'boolean', default: true, example: true }, + }, + }), + ]); + expect(result).not.toContain(`--includeComments 'true'`); + expect(result).not.toContain('--includeComments "true"'); + expect(result).toContain('# example: --includeComments'); + }); + + it('renders example=false as the negation form (--no-key)', () => { + const result = buildToolGuidance([ + makeManifest({ + parameters: { + includeComments: { type: 'boolean', default: true, example: false }, + }, + }), + ]); + expect(result).not.toContain(`--includeComments 'false'`); + expect(result).toContain('# example: --no-includeComments'); + }); }); describe('formatParam — no description', () => { diff --git a/tests/unit/cli/cli-command-factory.test.ts b/tests/unit/cli/cli-command-factory.test.ts index 9db02c7ba..2e25d7e64 100644 --- a/tests/unit/cli/cli-command-factory.test.ts +++ b/tests/unit/cli/cli-command-factory.test.ts @@ -144,6 +144,112 @@ describe('cliCommandFactory — flag generation', () => { expect(coreFn).toHaveBeenCalledWith(expect.objectContaining({ enabled: false })); }); + // Prod regression: `--includeComments true` was the dominant codex failure + // mode (9/14 runs in the 2026-05-09 corpus). Boolean flags must accept the + // natural value form too — agents reach for `--key true|false|yes|no|1|0` + // whenever they encounter a boolean, regardless of the declared toggle form. + describe('boolean flags — accept the natural value form', () => { + const cases: Array<{ argv: string[]; expected: boolean; label: string }> = [ + { argv: ['--enabled', 'true'], expected: true, label: 'space-separated true' }, + { argv: ['--enabled', 'false'], expected: false, label: 'space-separated false' }, + { argv: ['--enabled=true'], expected: true, label: 'equals-separated true' }, + { argv: ['--enabled=false'], expected: false, label: 'equals-separated false' }, + { argv: ['--enabled', 'yes'], expected: true, label: 'yes' }, + { argv: ['--enabled', 'no'], expected: false, label: 'no' }, + { argv: ['--enabled', '1'], expected: true, label: '1' }, + { argv: ['--enabled', '0'], expected: false, label: '0' }, + { argv: ['--enabled', 'TRUE'], expected: true, label: 'uppercase TRUE' }, + { argv: ['--enabled', 'False'], expected: false, label: 'mixed-case False' }, + ]; + for (const { argv, expected, label } of cases) { + it(`accepts ${label}`, async () => { + const coreFn = vi.fn().mockResolvedValue('ok'); + const def = makeToolDef({ + parameters: { + enabled: { + type: 'boolean', + describe: 'A boolean with allowNo', + optional: true, + default: true, + allowNo: true, + }, + }, + }); + const Cmd = createCLICommand(def, coreFn); + const cmd = new Cmd(argv, makeMockConfig() as never); + await cmd.run(); + expect(coreFn).toHaveBeenCalledWith(expect.objectContaining({ enabled: expected })); + }); + } + + it('canonical bare toggle (--enabled) still works', async () => { + const coreFn = vi.fn().mockResolvedValue('ok'); + const def = makeToolDef({ + parameters: { + enabled: { + type: 'boolean', + describe: 'A boolean with allowNo', + optional: true, + default: false, + allowNo: true, + }, + }, + }); + const Cmd = createCLICommand(def, coreFn); + const cmd = new Cmd(['--enabled'], makeMockConfig() as never); + await cmd.run(); + expect(coreFn).toHaveBeenCalledWith(expect.objectContaining({ enabled: true })); + }); + + it('rejects an unrecognised boolean value with a flag-parse envelope (no silent exit-2)', async () => { + const coreFn = vi.fn().mockResolvedValue('ok'); + const def = makeToolDef({ + parameters: { + enabled: { + type: 'boolean', + describe: 'A boolean with allowNo', + optional: true, + default: true, + allowNo: true, + }, + }, + }); + const Cmd = createCLICommand(def, coreFn); + const cmd = new Cmd(['--enabled', 'banana'], makeMockConfig() as never); + const logSpy = vi.spyOn(cmd, 'log'); + await expect(cmd.run()).rejects.toThrow(); + const output = JSON.parse(logSpy.mock.calls[0][0] as string) as { + success: boolean; + error: { type: string; flag?: string; got?: string; expected?: string }; + }; + expect(output.success).toBe(false); + expect(output.error.type).toBe('flag-parse'); + expect(output.error.flag).toBe('enabled'); + expect(output.error.got).toBe('banana'); + expect(output.error.expected).toMatch(/true|false|yes|no/i); + }); + + it('does not leak the value-form parsing onto non-boolean flags', async () => { + // Static guard: an enum/string flag with the same name shape (e.g. the + // agent typing `--status true`) must not be silently converted. + const coreFn = vi.fn().mockResolvedValue('ok'); + const def = makeToolDef({ + parameters: { + status: { + type: 'enum', + options: ['open', 'closed'], + describe: 'enum', + required: true, + }, + }, + }); + const Cmd = createCLICommand(def, coreFn); + const cmd = new Cmd(['--status', 'true'], makeMockConfig() as never); + // oclif rejects 'true' since it's not in the enum options + await expect(cmd.run()).rejects.toThrow(); + }); + }); + it('generates enum flags with restricted options', async () => { const coreFn = vi.fn().mockResolvedValue('ok'); const def = makeToolDef({ @@ -482,3 +588,85 @@ describe('cliCommandFactory — JSON output format', () => { expect((Cmd as { description?: string }).description).toBe('My test tool description'); }); }); + +// Spec 014: every cascade-tools failure must reach the agent as a structured +// envelope. The 2026-05-09 corpus showed `--includeComments true` and +// missing-required-flag failures bypassed it (exit 2 + empty stdout). After +// the parse-error wrap, every oclif CLIParseError is mapped onto the envelope. +describe('cliCommandFactory — parse-error envelope wrapping (spec 014, #4)', () => { + it('emits a missing-required envelope when a required string flag is omitted', async () => { + const coreFn = vi.fn().mockResolvedValue('ok'); + const def = makeToolDef({ + parameters: { + name: { type: 'string', describe: 'A name', required: true }, + }, + }); + const Cmd = createCLICommand(def, coreFn); + const cmd = new Cmd([], makeMockConfig() as never); + const logSpy = vi.spyOn(cmd, 'log'); + await expect(cmd.run()).rejects.toThrow(); + expect(coreFn).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledTimes(1); + const output = JSON.parse(logSpy.mock.calls[0][0] as string) as { + success: boolean; + error: { type: string; flag?: string }; + }; + expect(output.success).toBe(false); + expect(output.error.type).toBe('missing-required'); + expect(output.error.flag).toBe('name'); + }); + + it('emits an enum-mismatch envelope when a flag value is outside the declared options', async () => { + const coreFn = vi.fn().mockResolvedValue('ok'); + const def = makeToolDef({ + parameters: { + status: { + type: 'enum', + options: ['open', 'closed'], + describe: 'Status', + required: true, + }, + }, + }); + const Cmd = createCLICommand(def, coreFn); + const cmd = new Cmd(['--status', 'banana'], makeMockConfig() as never); + const logSpy = vi.spyOn(cmd, 'log'); + await expect(cmd.run()).rejects.toThrow(); + expect(coreFn).not.toHaveBeenCalled(); + const output = JSON.parse(logSpy.mock.calls[0][0] as string) as { + success: boolean; + error: { type: string; flag?: string; got?: string; expected?: string }; + }; + expect(output.success).toBe(false); + expect(output.error.type).toBe('enum-mismatch'); + expect(output.error.flag).toBe('status'); + expect(output.error.got).toBe('banana'); + expect(output.error.expected).toContain('open'); + expect(output.error.expected).toContain('closed'); + }); + + it('preserves the existing unknown-flag envelope (regression net for spec 014)', async () => { + const coreFn = vi.fn().mockResolvedValue('ok'); + const def = makeToolDef({ + parameters: { + comments: { + type: 'array', + items: 'object', + describe: 'comments', + optional: true, + cliAliases: ['comment'], + }, + }, + }); + const Cmd = createCLICommand(def, coreFn); + const cmd = new Cmd(['--commentt', 'oops'], makeMockConfig() as never); + const logSpy = vi.spyOn(cmd, 'log'); + await expect(cmd.run()).rejects.toThrow(); + const output = JSON.parse(logSpy.mock.calls[0][0] as string) as { + success: boolean; + error: { type: string; flag?: string; hint?: string }; + }; + expect(output.success).toBe(false); + expect(output.error.type).toBe('unknown-flag'); + }); +}); diff --git a/tests/unit/gadgets/shared/promptExamplesGrammar.test.ts b/tests/unit/gadgets/shared/promptExamplesGrammar.test.ts new file mode 100644 index 000000000..82abb156d --- /dev/null +++ b/tests/unit/gadgets/shared/promptExamplesGrammar.test.ts @@ -0,0 +1,86 @@ +/** + * Static guard — every cascade-tools example rendered into the agent system + * prompt must mirror the canonical CLI grammar. + * + * Spec 014 regression net for prod incident 2026-05-09: 9/14 codex runs + * hit `--includeComments true` because the prompt example said exactly that + * while oclif's `Flags.boolean({ allowNo: true })` rejects the value form + * (silent exit 2, empty stdout, contract bypass). The rule: + * + * - boolean param example=true → `# example: --` + * - boolean param example=false → `# example: --no-` + * - never `-- 'true'` / `-- 'false'` + * + * This test iterates every real ToolDefinition shipped in the four category + * barrels and asserts the invariant against the rendered tool guidance. A new + * gadget that ships a boolean+example combo will pass automatically; one that + * hand-rolls a divergent renderer will fail loudly with a precise file:line. + */ + +import { describe, expect, it } from 'vitest'; + +import { buildToolGuidance } from '../../../../src/backends/shared/nativeToolPrompts.js'; +import * as githubDefs from '../../../../src/gadgets/github/definitions.js'; +import * as pmDefs from '../../../../src/gadgets/pm/definitions.js'; +import * as sentryDefs from '../../../../src/gadgets/sentry/definitions.js'; +import * as sessionDefs from '../../../../src/gadgets/session/definitions.js'; +import { generateToolManifest } from '../../../../src/gadgets/shared/manifestGenerator.js'; +import type { ToolDefinition } from '../../../../src/gadgets/shared/toolDefinition.js'; + +function collectAllDefs(): ToolDefinition[] { + const candidates: unknown[] = [ + ...Object.values(githubDefs), + ...Object.values(pmDefs), + ...Object.values(sentryDefs), + ...Object.values(sessionDefs), + ]; + const defs: ToolDefinition[] = []; + for (const c of candidates) { + if (c && typeof c === 'object' && 'name' in c && 'parameters' in c) { + defs.push(c as ToolDefinition); + } + } + return defs; +} + +const allDefs = collectAllDefs(); + +describe('prompt-rendered examples — CLI grammar correctness', () => { + it('discovered at least the 23 known gadget definitions', () => { + expect(allDefs.length).toBeGreaterThanOrEqual(23); + }); + + for (const def of allDefs) { + const booleanFlagNames = Object.entries(def.parameters) + .filter(([, p]) => p.type === 'boolean' && !p.gadgetOnly) + .map(([k]) => k); + + if (booleanFlagNames.length === 0) continue; + + it(`${def.name}: boolean flag examples mirror canonical toggle grammar`, () => { + const manifest = generateToolManifest(def); + const rendered = buildToolGuidance([manifest]); + + for (const flagName of booleanFlagNames) { + // The prod regression: `# example: --includeComments 'true'`. + // Oclif rejects that form. Assert the renderer never emits it. + expect( + rendered, + `${def.name}.${flagName} must not render --${flagName} 'true'`, + ).not.toContain(`--${flagName} 'true'`); + expect( + rendered, + `${def.name}.${flagName} must not render --${flagName} "true"`, + ).not.toContain(`--${flagName} "true"`); + expect( + rendered, + `${def.name}.${flagName} must not render --${flagName} 'false'`, + ).not.toContain(`--${flagName} 'false'`); + expect( + rendered, + `${def.name}.${flagName} must not render --${flagName} "false"`, + ).not.toContain(`--${flagName} "false"`); + } + }); + } +}); From ec5e2d6163c8ffd6f63474a43151a12bf74ca181 Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Sat, 9 May 2026 10:16:47 +0000 Subject: [PATCH 08/18] chore(cascade-tools): add biome-ignore comments for argv/parse-error taxonomies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the existing pattern at formatParam / formatExampleLine — both flagged for the same noExcessiveCognitiveComplexity rule with the same "-taxonomy" rationale. The two new functions added in the previous commit (massageBooleanFlagValues, FactoryCommand.execute's catch chain) are also shape-driven taxonomies that read more clearly inline than split. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/gadgets/shared/cliCommandFactory.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/gadgets/shared/cliCommandFactory.ts b/src/gadgets/shared/cliCommandFactory.ts index ca14d506f..129a7267f 100644 --- a/src/gadgets/shared/cliCommandFactory.ts +++ b/src/gadgets/shared/cliCommandFactory.ts @@ -338,6 +338,7 @@ function normalizeBoolValue(raw: string): boolean | null { * (starts with `--`) — that token belongs to a different flag, not to the * preceding boolean. Bare-toggle invocations stay untouched. */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: argv-shape taxonomy (--key=value, --key value, bare toggle) function massageBooleanFlagValues( argv: readonly string[] | undefined, booleanFlags: ReadonlySet, @@ -815,6 +816,7 @@ export function createCLICommand( static override flags = flagsRecord; static override examples = staticExamples; + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: parse-error classification taxonomy (unknown-flag / classified / runtime / re-throw) async execute(): Promise { // Build a sink that routes emitCliError output through the instance's // log/exit — lets tests spy on instance.log and instance.exit. From 1dde404c4451f15703df945b7a3c09490dba280d Mon Sep 17 00:00:00 2001 From: aaight Date: Sat, 9 May 2026 12:20:20 +0200 Subject: [PATCH 09/18] refactor(triggers): extract follow-up dispatch helpers (#1280) * refactor(triggers): extract follow-up dispatch helpers * fix(triggers): restore non-fatal boundary around recursive review pipeline execution The refactor moved the recursive `runAgentExecutionPipeline` call for post-completion review outside of any try/catch, allowing exceptions from the nested review pipeline (lifecycle hooks, validation, engine execution) to bubble up and fail the implementation run after it had already succeeded and produced a PR. Restore the best-effort boundary by wrapping the recursive call in a try/catch that logs a warn and lets the implementation pipeline complete. Add a facade test covering the case where the review pipeline throws. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Cascade Bot Co-authored-by: Claude Sonnet 4.6 --- src/triggers/shared/agent-auto-debug.ts | 35 ++ src/triggers/shared/agent-execution.ts | 298 ++---------------- src/triggers/shared/post-completion-review.ts | 84 +++++ src/triggers/shared/splitting-auto-chain.ts | 133 ++++++++ .../triggers/shared/agent-auto-debug.test.ts | 97 ++++++ .../triggers/shared/agent-execution.test.ts | 35 ++ .../shared/post-completion-review.test.ts | 149 +++++++++ .../shared/splitting-auto-chain.test.ts | 175 ++++++++++ 8 files changed, 731 insertions(+), 275 deletions(-) create mode 100644 src/triggers/shared/agent-auto-debug.ts create mode 100644 src/triggers/shared/post-completion-review.ts create mode 100644 src/triggers/shared/splitting-auto-chain.ts create mode 100644 tests/unit/triggers/shared/agent-auto-debug.test.ts create mode 100644 tests/unit/triggers/shared/post-completion-review.test.ts create mode 100644 tests/unit/triggers/shared/splitting-auto-chain.test.ts diff --git a/src/triggers/shared/agent-auto-debug.ts b/src/triggers/shared/agent-auto-debug.ts new file mode 100644 index 000000000..addb2b5f3 --- /dev/null +++ b/src/triggers/shared/agent-auto-debug.ts @@ -0,0 +1,35 @@ +import type { AgentResult, CascadeConfig, ProjectConfig } from '../../types/index.js'; +import { logger } from '../../utils/logging.js'; +import { triggerDebugAnalysis } from './debug-runner.js'; +import { shouldTriggerDebug } from './debug-trigger.js'; + +export type AutoDebugResult = + | { triggered: false; reason: 'missing-run-id' | 'not-eligible' } + | { triggered: true; runId: string; workItemId?: string }; + +/** + * Trigger auto-debug analysis for a failed/timed_out agent run. + * + * The debug analysis remains fire-and-forget. Failures are logged from the + * async branch and do not affect the completed agent run. + */ +export async function triggerAutoDebugIfNeeded( + agentResult: AgentResult, + project: ProjectConfig, + config: CascadeConfig, +): Promise { + if (!agentResult.runId) return { triggered: false, reason: 'missing-run-id' }; + + const debugTarget = await shouldTriggerDebug(agentResult.runId); + if (!debugTarget) return { triggered: false, reason: 'not-eligible' }; + + triggerDebugAnalysis(debugTarget.runId, project, config, debugTarget.workItemId).catch((err) => + logger.error('Auto-debug failed', { error: String(err) }), + ); + + return { + triggered: true, + runId: debugTarget.runId, + workItemId: debugTarget.workItemId, + }; +} diff --git a/src/triggers/shared/agent-execution.ts b/src/triggers/shared/agent-execution.ts index 067e3bdca..1a3aab471 100644 --- a/src/triggers/shared/agent-execution.ts +++ b/src/triggers/shared/agent-execution.ts @@ -1,23 +1,11 @@ import { getAgentProfile } from '../../agents/definitions/profiles.js'; import type { LifecycleHooks } from '../../agents/definitions/schema.js'; import { runAgent } from '../../agents/registry.js'; -import { getPMProvider } from '../../pm/context.js'; -import { - createPMProvider, - hasAutoLabel, - PMLifecycleManager, - resolveProjectPMConfig, -} from '../../pm/index.js'; -import { - buildReviewDispatchKey, - claimReviewDispatch, -} from '../../triggers/github/review-dispatch-dedup.js'; -import { checkTriggerEnabled } from '../../triggers/shared/trigger-check.js'; +import { createPMProvider, PMLifecycleManager, resolveProjectPMConfig } from '../../pm/index.js'; import type { AgentResult, CascadeConfig, ProjectConfig } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; -import { extractPRNumber } from '../../utils/prUrl.js'; -import { parseRepoFullName } from '../../utils/repo.js'; import type { TriggerResult } from '../types.js'; +import { triggerAutoDebugIfNeeded } from './agent-auto-debug.js'; import { checkPreRunBudget, prepareAgentExecutionLifecycle, @@ -31,102 +19,11 @@ import { persistPreRunWorkItems, prepareAgentWorkItem, } from './agent-work-items.js'; -import { isPipelineAtCapacity } from './backlog-check.js'; -import { triggerDebugAnalysis } from './debug-runner.js'; -import { shouldTriggerDebug } from './debug-trigger.js'; +import { buildPostCompletionReviewDispatch } from './post-completion-review.js'; +import { buildSplittingAutoChainDispatch } from './splitting-auto-chain.js'; export type { AgentExecutionConfig } from './agent-execution-types.js'; -/** - * Dispatch a review agent after a successful implementation run, if the PR's - * CI is green and no review has been dispatched yet. - * - * Uses `claimReviewDispatch` with the same dedup key format as the - * `check-suite-success` trigger, so the two paths cannot double-enqueue. - * If CI isn't green yet, does nothing — the webhook-triggered path will - * handle it when CI finishes. - * - * Runs inside the worker container, before exit. Uses the same recursive - * `runAgentExecutionPipeline` pattern as the splitting → backlog-manager chain. - * - * Best-effort: errors are logged as warn but never break the implementation - * pipeline. - */ -async function tryDispatchPostCompletionReview( - agentResult: AgentResult & { prUrl: string }, - project: ProjectConfig & { repo: string }, - workItemId: string | undefined, - config: CascadeConfig, - executionConfig: AgentExecutionConfig, -): Promise { - try { - const prNumber = extractPRNumber(agentResult.prUrl); - if (!prNumber) return; - - const { owner, repo } = parseRepoFullName(project.repo); - const { githubClient } = await import('../../github/client.js'); - - const pr = await githubClient.getPR(owner, repo, prNumber); - const headSha = pr.headSha; - if (!headSha) return; - - const checkStatus = await githubClient.getCheckSuiteStatus(owner, repo, headSha); - if (!checkStatus.allPassing) { - logger.debug('Skipping post-completion review: CI not all passing', { - prNumber, - workItemId, - }); - return; - } - - const dedupKey = buildReviewDispatchKey(owner, repo, prNumber, headSha); - if (!(await claimReviewDispatch(dedupKey, 'post-completion-hook', { prNumber, headSha }))) { - logger.info('Skipping post-completion review: already dispatched', { - prNumber, - workItemId, - dedupKey, - }); - return; - } - - logger.info('Post-completion review dispatch: firing review for implementation PR', { - prNumber, - workItemId, - headSha, - }); - - const reviewResult: TriggerResult = { - agentType: 'review', - agentInput: { - prNumber, - prBranch: pr.headRef, - repoFullName: project.repo, - headSha, - triggerType: 'ci-success', - triggerEvent: 'scm:check-suite-success', - workItemId, - }, - prNumber, - prUrl: agentResult.prUrl, - prTitle: pr.title, - workItemId, - }; - - await runAgentExecutionPipeline(reviewResult, project, config, { - ...executionConfig, - skipPrepareForAgent: true, - skipHandleFailure: true, - logLabel: 'review (post-completion)', - }); - } catch (err) { - logger.warn('Post-completion review dispatch failed (non-fatal)', { - prUrl: agentResult.prUrl, - workItemId, - error: String(err), - }); - } -} - /** * Shared agent execution pipeline. * @@ -290,18 +187,28 @@ export async function runAgentExecutionPipeline( // timing (spec 007). Uses the same recursive pattern as the splitting → // backlog-manager chain below. if (agentType === 'implementation' && agentResult.success && agentResult.prUrl && project.repo) { - await tryDispatchPostCompletionReview( - agentResult as AgentResult & { prUrl: string }, - project as ProjectConfig & { repo: string }, - workItemId, - config, - executionConfig, - ); + const reviewResult = await buildPostCompletionReviewDispatch(agentResult, project, workItemId); + if (reviewResult) { + try { + await runAgentExecutionPipeline(reviewResult, project, config, { + ...executionConfig, + skipPrepareForAgent: true, + skipHandleFailure: true, + logLabel: 'review (post-completion)', + }); + } catch (err) { + logger.warn('Post-completion review pipeline failed (non-fatal)', { + prUrl: agentResult.prUrl, + workItemId, + error: String(err), + }); + } + } } // After a successful splitting run, propagate auto label and optionally chain backlog-manager if (agentType === 'splitting' && agentResult.success && workItemId) { - const chainResult = await propagateAutoLabelAfterSplitting(workItemId, project); + const chainResult = await buildSplittingAutoChainDispatch(workItemId, project); if (chainResult) { await runAgentExecutionPipeline(chainResult, project, config, { ...executionConfig, @@ -312,164 +219,5 @@ export async function runAgentExecutionPipeline( } } - await tryAutoDebug(agentResult, project, config); -} - -/** - * After a successful splitting agent run, propagate the 'auto' label to all - * cards in the backlog list and immediately chain to the backlog-manager agent. - * - * Only runs if the parent work item has the 'auto' label configured. - * - * NOTE: This propagates the label to ALL items currently in the backlog, not just - * those created by the splitting agent. This is intentional to enable batch auto-processing. - */ -async function propagateAutoLabelAfterSplitting( - workItemId: string, - project: ProjectConfig, -): Promise { - const pmConfig = resolveProjectPMConfig(project); - const provider = getPMProvider(); - - // Check if parent has the auto label - let parentWorkItem: Awaited>; - try { - parentWorkItem = await provider.getWorkItem(workItemId); - } catch (err) { - logger.warn('propagateAutoLabelAfterSplitting: failed to fetch parent work item', { - workItemId, - error: String(err), - }); - return null; - } - - if (!hasAutoLabel(parentWorkItem.labels, pmConfig)) { - return null; - } - - const autoLabelId = pmConfig.labels.auto; - if (!autoLabelId) return null; - - // Resolve the actual label ID from the matched parent work item label. - // pmConfig.labels.auto may be a human-readable name string (e.g. 'cascade-auto') - // rather than a UUID when the project was not explicitly configured with UUIDs. - // Providers like Linear require UUIDs for addLabel — passing a name string causes - // resolveLabelId() to return null and the operation silently no-ops. - // By resolving the id from the parent's matched label we always pass the correct - // identifier regardless of config format. - // NOTE: The UUID check is scoped to Linear only. Trello uses 24-character MongoDB - // Object IDs and JIRA uses name strings — both are valid non-UUID formats for those - // providers and should not produce log noise in happy paths. - const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - if (project.pm.type === 'linear' && !UUID_REGEX.test(autoLabelId)) { - logger.warn( - 'propagateAutoLabelAfterSplitting: labels.auto is not a UUID; resolving ID from parent labels', - { autoLabelId }, - ); - } - const matchedLabel = parentWorkItem.labels.find( - (l) => l.id === autoLabelId || l.name === autoLabelId, - ); - const resolvedAutoLabelId = matchedLabel ? matchedLabel.id : autoLabelId; - - // List backlog items via the unified call shape — provider self-resolves - // scope (Trello list / JIRA project / Linear team) and maps the CASCADE - // status key to its native identifier from its own config. - let backlogItems: Awaited>; - try { - backlogItems = await provider.listWorkItems(undefined, { status: 'backlog' }); - } catch (err) { - logger.warn('propagateAutoLabelAfterSplitting: failed to list backlog items', { - workItemId, - error: String(err), - }); - return null; - } - - logger.info('Propagating auto label to backlog items after splitting', { - parentWorkItemId: workItemId, - backlogItemCount: backlogItems.length, - }); - - // Label all backlog items that don't already have the auto label - await Promise.all( - backlogItems - .filter((item) => !hasAutoLabel(item.labels, pmConfig)) - .map((item) => - provider.addLabel(item.id, resolvedAutoLabelId).catch((err) => - logger.warn('Failed to add auto label to backlog item', { - itemId: item.id, - error: String(err), - }), - ), - ), - ); - - // Skip chaining if the backlog is empty — no items to process - if (backlogItems.length === 0) { - logger.info( - 'propagateAutoLabelAfterSplitting: backlog is empty after splitting, skipping backlog-manager chain', - { workItemId }, - ); - return null; - } - - // Check if backlog-manager trigger is enabled, then chain to it - const backlogManagerEnabled = await checkTriggerEnabled( - project.id, - 'backlog-manager', - 'internal:auto-chain', - 'splitting-auto-propagate', - ); - if (!backlogManagerEnabled) { - logger.info( - 'propagateAutoLabelAfterSplitting: backlog-manager trigger not enabled, skipping chain', - { workItemId }, - ); - return null; - } - - // Check pipeline capacity before chaining to backlog-manager - const capacityResult = await isPipelineAtCapacity(project, provider); - if (capacityResult.atCapacity) { - logger.info( - 'propagateAutoLabelAfterSplitting: pipeline at capacity, skipping backlog-manager chain', - { - workItemId, - reason: capacityResult.reason, - inFlightCount: capacityResult.inFlightCount, - limit: capacityResult.limit, - availableSlots: capacityResult.availableSlots, - }, - ); - return null; - } - - logger.info('Chaining to backlog-manager after splitting with auto label', { - parentWorkItemId: workItemId, - }); - - return { - agentType: 'backlog-manager', - // Include workItemId so PM operations (progress, lifecycle) have the work item ID. - agentInput: { triggerEvent: 'internal:auto-chain', workItemId: workItemId }, - workItemId, - }; -} - -/** - * Trigger auto-debug analysis for a failed/timed_out agent run. - */ -async function tryAutoDebug( - agentResult: AgentResult, - project: ProjectConfig, - config: CascadeConfig, -): Promise { - if (!agentResult.runId) return; - const debugTarget = await shouldTriggerDebug(agentResult.runId); - if (debugTarget) { - triggerDebugAnalysis(debugTarget.runId, project, config, debugTarget.workItemId).catch((err) => - logger.error('Auto-debug failed', { error: String(err) }), - ); - } + await triggerAutoDebugIfNeeded(agentResult, project, config); } diff --git a/src/triggers/shared/post-completion-review.ts b/src/triggers/shared/post-completion-review.ts new file mode 100644 index 000000000..34ddbeb8f --- /dev/null +++ b/src/triggers/shared/post-completion-review.ts @@ -0,0 +1,84 @@ +import { githubClient } from '../../github/client.js'; +import { + buildReviewDispatchKey, + claimReviewDispatch, +} from '../../triggers/github/review-dispatch-dedup.js'; +import type { AgentResult, ProjectConfig } from '../../types/index.js'; +import { logger } from '../../utils/logging.js'; +import { extractPRNumber } from '../../utils/prUrl.js'; +import { parseRepoFullName } from '../../utils/repo.js'; +import type { TriggerResult } from '../types.js'; + +/** + * Build a review dispatch intent after a successful implementation run, if the + * PR's CI is green and no review has been dispatched yet. + * + * Best-effort: lookup, CI, and dedup errors are logged but never break the + * implementation pipeline. + */ +export async function buildPostCompletionReviewDispatch( + agentResult: AgentResult & { prUrl?: string }, + project: ProjectConfig, + workItemId: string | undefined, +): Promise { + if (!agentResult.success || !agentResult.prUrl || !project.repo) return null; + + try { + const prNumber = extractPRNumber(agentResult.prUrl); + if (!prNumber) return null; + + const { owner, repo } = parseRepoFullName(project.repo); + const pr = await githubClient.getPR(owner, repo, prNumber); + const headSha = pr.headSha; + if (!headSha) return null; + + const checkStatus = await githubClient.getCheckSuiteStatus(owner, repo, headSha); + if (!checkStatus.allPassing) { + logger.debug('Skipping post-completion review: CI not all passing', { + prNumber, + workItemId, + }); + return null; + } + + const dedupKey = buildReviewDispatchKey(owner, repo, prNumber, headSha); + if (!(await claimReviewDispatch(dedupKey, 'post-completion-hook', { prNumber, headSha }))) { + logger.info('Skipping post-completion review: already dispatched', { + prNumber, + workItemId, + dedupKey, + }); + return null; + } + + logger.info('Post-completion review dispatch: firing review for implementation PR', { + prNumber, + workItemId, + headSha, + }); + + return { + agentType: 'review', + agentInput: { + prNumber, + prBranch: pr.headRef, + repoFullName: project.repo, + headSha, + triggerType: 'ci-success', + triggerEvent: 'scm:check-suite-success', + workItemId, + }, + prNumber, + prUrl: agentResult.prUrl, + prTitle: pr.title, + workItemId, + }; + } catch (err) { + logger.warn('Post-completion review dispatch failed (non-fatal)', { + prUrl: agentResult.prUrl, + workItemId, + error: String(err), + }); + return null; + } +} diff --git a/src/triggers/shared/splitting-auto-chain.ts b/src/triggers/shared/splitting-auto-chain.ts new file mode 100644 index 000000000..b541335aa --- /dev/null +++ b/src/triggers/shared/splitting-auto-chain.ts @@ -0,0 +1,133 @@ +import { getPMProvider } from '../../pm/context.js'; +import { hasAutoLabel, resolveProjectPMConfig } from '../../pm/index.js'; +import type { ProjectConfig } from '../../types/index.js'; +import { logger } from '../../utils/logging.js'; +import type { TriggerResult } from '../types.js'; +import { isPipelineAtCapacity } from './backlog-check.js'; +import { checkTriggerEnabled } from './trigger-check.js'; + +/** + * After a successful splitting agent run, propagate the 'auto' label to all + * cards in the backlog list and return a backlog-manager dispatch intent. + * + * Only runs if the parent work item has the 'auto' label configured. + * + * NOTE: This propagates the label to ALL items currently in the backlog, not just + * those created by the splitting agent. This is intentional to enable batch auto-processing. + */ +export async function buildSplittingAutoChainDispatch( + workItemId: string, + project: ProjectConfig, +): Promise { + const pmConfig = resolveProjectPMConfig(project); + const provider = getPMProvider(); + + let parentWorkItem: Awaited>; + try { + parentWorkItem = await provider.getWorkItem(workItemId); + } catch (err) { + logger.warn('propagateAutoLabelAfterSplitting: failed to fetch parent work item', { + workItemId, + error: String(err), + }); + return null; + } + + if (!hasAutoLabel(parentWorkItem.labels, pmConfig)) { + return null; + } + + const autoLabelId = pmConfig.labels.auto; + if (!autoLabelId) return null; + + // Resolve the actual label ID from the matched parent work item label. + // pmConfig.labels.auto may be a human-readable name string rather than a + // provider-native ID. + const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (project.pm.type === 'linear' && !UUID_REGEX.test(autoLabelId)) { + logger.warn( + 'propagateAutoLabelAfterSplitting: labels.auto is not a UUID; resolving ID from parent labels', + { autoLabelId }, + ); + } + const matchedLabel = parentWorkItem.labels.find( + (l) => l.id === autoLabelId || l.name === autoLabelId, + ); + const resolvedAutoLabelId = matchedLabel ? matchedLabel.id : autoLabelId; + + let backlogItems: Awaited>; + try { + backlogItems = await provider.listWorkItems(undefined, { status: 'backlog' }); + } catch (err) { + logger.warn('propagateAutoLabelAfterSplitting: failed to list backlog items', { + workItemId, + error: String(err), + }); + return null; + } + + logger.info('Propagating auto label to backlog items after splitting', { + parentWorkItemId: workItemId, + backlogItemCount: backlogItems.length, + }); + + await Promise.all( + backlogItems + .filter((item) => !hasAutoLabel(item.labels, pmConfig)) + .map((item) => + provider.addLabel(item.id, resolvedAutoLabelId).catch((err) => + logger.warn('Failed to add auto label to backlog item', { + itemId: item.id, + error: String(err), + }), + ), + ), + ); + + if (backlogItems.length === 0) { + logger.info( + 'propagateAutoLabelAfterSplitting: backlog is empty after splitting, skipping backlog-manager chain', + { workItemId }, + ); + return null; + } + + const backlogManagerEnabled = await checkTriggerEnabled( + project.id, + 'backlog-manager', + 'internal:auto-chain', + 'splitting-auto-propagate', + ); + if (!backlogManagerEnabled) { + logger.info( + 'propagateAutoLabelAfterSplitting: backlog-manager trigger not enabled, skipping chain', + { workItemId }, + ); + return null; + } + + const capacityResult = await isPipelineAtCapacity(project, provider); + if (capacityResult.atCapacity) { + logger.info( + 'propagateAutoLabelAfterSplitting: pipeline at capacity, skipping backlog-manager chain', + { + workItemId, + reason: capacityResult.reason, + inFlightCount: capacityResult.inFlightCount, + limit: capacityResult.limit, + availableSlots: capacityResult.availableSlots, + }, + ); + return null; + } + + logger.info('Chaining to backlog-manager after splitting with auto label', { + parentWorkItemId: workItemId, + }); + + return { + agentType: 'backlog-manager', + agentInput: { triggerEvent: 'internal:auto-chain', workItemId }, + workItemId, + }; +} diff --git a/tests/unit/triggers/shared/agent-auto-debug.test.ts b/tests/unit/triggers/shared/agent-auto-debug.test.ts new file mode 100644 index 000000000..32bc8965b --- /dev/null +++ b/tests/unit/triggers/shared/agent-auto-debug.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AgentResult, CascadeConfig, ProjectConfig } from '../../../../src/types/index.js'; + +const { mockShouldTriggerDebug, mockTriggerDebugAnalysis, mockLogger } = vi.hoisted(() => ({ + mockShouldTriggerDebug: vi.fn(), + mockTriggerDebugAnalysis: vi.fn(), + mockLogger: { + error: vi.fn(), + }, +})); + +vi.mock('../../../../src/triggers/shared/debug-trigger.js', () => ({ + shouldTriggerDebug: (...args: unknown[]) => mockShouldTriggerDebug(...args), +})); + +vi.mock('../../../../src/triggers/shared/debug-runner.js', () => ({ + triggerDebugAnalysis: (...args: unknown[]) => mockTriggerDebugAnalysis(...args), +})); + +vi.mock('../../../../src/utils/logging.js', () => ({ + logger: mockLogger, +})); + +import { triggerAutoDebugIfNeeded } from '../../../../src/triggers/shared/agent-auto-debug.js'; + +const PROJECT = { id: 'project-1', pm: { type: 'trello' } } as ProjectConfig; +const CONFIG = {} as CascadeConfig; + +describe('triggerAutoDebugIfNeeded', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockShouldTriggerDebug.mockResolvedValue(null); + mockTriggerDebugAnalysis.mockResolvedValue(undefined); + }); + + it('returns missing-run-id and does not check eligibility without runId', async () => { + const result = await triggerAutoDebugIfNeeded( + { success: false, output: '', error: 'failed' } as AgentResult, + PROJECT, + CONFIG, + ); + + expect(result).toEqual({ triggered: false, reason: 'missing-run-id' }); + expect(mockShouldTriggerDebug).not.toHaveBeenCalled(); + }); + + it('returns not-eligible when debug trigger check skips the run', async () => { + const result = await triggerAutoDebugIfNeeded( + { success: false, output: '', runId: 'run-1' } as AgentResult, + PROJECT, + CONFIG, + ); + + expect(result).toEqual({ triggered: false, reason: 'not-eligible' }); + expect(mockShouldTriggerDebug).toHaveBeenCalledWith('run-1'); + expect(mockTriggerDebugAnalysis).not.toHaveBeenCalled(); + }); + + it('fires triggerDebugAnalysis asynchronously when eligible', async () => { + mockShouldTriggerDebug.mockResolvedValueOnce({ + runId: 'run-1', + agentType: 'implementation', + workItemId: 'card-1', + }); + + const result = await triggerAutoDebugIfNeeded( + { success: false, output: '', runId: 'run-1' } as AgentResult, + PROJECT, + CONFIG, + ); + + expect(result).toEqual({ triggered: true, runId: 'run-1', workItemId: 'card-1' }); + expect(mockTriggerDebugAnalysis).toHaveBeenCalledWith('run-1', PROJECT, CONFIG, 'card-1'); + }); + + it('logs asynchronous debug dispatch failures without throwing', async () => { + mockShouldTriggerDebug.mockResolvedValueOnce({ + runId: 'run-1', + agentType: 'implementation', + workItemId: 'card-1', + }); + mockTriggerDebugAnalysis.mockRejectedValueOnce(new Error('debug failed')); + + await expect( + triggerAutoDebugIfNeeded( + { success: false, output: '', runId: 'run-1' } as AgentResult, + PROJECT, + CONFIG, + ), + ).resolves.toEqual({ triggered: true, runId: 'run-1', workItemId: 'card-1' }); + await Promise.resolve(); + + expect(mockLogger.error).toHaveBeenCalledWith('Auto-debug failed', { + error: 'Error: debug failed', + }); + }); +}); diff --git a/tests/unit/triggers/shared/agent-execution.test.ts b/tests/unit/triggers/shared/agent-execution.test.ts index 5414f3f3e..672bfb742 100644 --- a/tests/unit/triggers/shared/agent-execution.test.ts +++ b/tests/unit/triggers/shared/agent-execution.test.ts @@ -1095,4 +1095,39 @@ describe('post-completion review dispatch (via runAgentExecutionPipeline)', () = expect.objectContaining({ error: expect.any(String) }), ); }); + + it('does not break the implementation pipeline when the nested review pipeline throws', async () => { + // Arrange: implementation succeeds and green CI claims the dedup key; + // review pipeline then throws during its own execution. + mockRunAgent + .mockResolvedValueOnce({ + success: true, + output: '', + runId: 'run-impl', + prUrl: 'https://github.com/acme/myapp/pull/42', + }) + .mockRejectedValueOnce(new Error('review pipeline exploded')); + + // Pipeline should resolve (not reject) even though the nested review run threw. + await expect( + runAgentExecutionPipeline( + { agentType: 'implementation', agentInput: {}, workItemId: 'card-1' }, + PROJECT, + CONFIG, + ), + ).resolves.toBeUndefined(); + + // Both the implementation and review agents were attempted. + expect(mockRunAgent).toHaveBeenCalledTimes(2); + + // The failure is logged as a non-fatal warning, not rethrown. + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Post-completion review pipeline failed (non-fatal)', + expect.objectContaining({ + prUrl: 'https://github.com/acme/myapp/pull/42', + workItemId: 'card-1', + error: 'Error: review pipeline exploded', + }), + ); + }); }); diff --git a/tests/unit/triggers/shared/post-completion-review.test.ts b/tests/unit/triggers/shared/post-completion-review.test.ts new file mode 100644 index 000000000..4caa23a30 --- /dev/null +++ b/tests/unit/triggers/shared/post-completion-review.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AgentResult, ProjectConfig } from '../../../../src/types/index.js'; + +const { mockGithubClient, mockClaimReviewDispatch, mockBuildReviewDispatchKey, mockLogger } = + vi.hoisted(() => ({ + mockGithubClient: { + getPR: vi.fn(), + getCheckSuiteStatus: vi.fn(), + }, + mockClaimReviewDispatch: vi.fn(), + mockBuildReviewDispatchKey: vi.fn(), + mockLogger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + })); + +vi.mock('../../../../src/github/client.js', () => ({ + githubClient: mockGithubClient, +})); + +vi.mock('../../../../src/triggers/github/review-dispatch-dedup.js', () => ({ + buildReviewDispatchKey: (...args: unknown[]) => mockBuildReviewDispatchKey(...args), + claimReviewDispatch: (...args: unknown[]) => mockClaimReviewDispatch(...args), +})); + +vi.mock('../../../../src/utils/logging.js', () => ({ + logger: mockLogger, +})); + +import { buildPostCompletionReviewDispatch } from '../../../../src/triggers/shared/post-completion-review.js'; + +const PROJECT = { + id: 'project-1', + repo: 'acme/myapp', + pm: { type: 'trello' }, +} as ProjectConfig; + +const SUCCESS_WITH_PR = { + success: true, + output: '', + runId: 'run-1', + prUrl: 'https://github.com/acme/myapp/pull/42', +} as AgentResult & { prUrl: string }; + +describe('buildPostCompletionReviewDispatch', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGithubClient.getPR.mockResolvedValue({ + title: 'feat: test PR', + headSha: 'sha-123', + headRef: 'feature/test', + }); + mockGithubClient.getCheckSuiteStatus.mockResolvedValue({ allPassing: true }); + mockBuildReviewDispatchKey.mockReturnValue('acme/myapp:42:sha-123'); + mockClaimReviewDispatch.mockResolvedValue(true); + }); + + it('returns review TriggerResult after implementation success with PR, head SHA, green CI, and dedup claim', async () => { + const result = await buildPostCompletionReviewDispatch(SUCCESS_WITH_PR, PROJECT, 'card-1'); + + expect(result).toEqual({ + agentType: 'review', + agentInput: { + prNumber: 42, + prBranch: 'feature/test', + repoFullName: 'acme/myapp', + headSha: 'sha-123', + triggerType: 'ci-success', + triggerEvent: 'scm:check-suite-success', + workItemId: 'card-1', + }, + prNumber: 42, + prUrl: 'https://github.com/acme/myapp/pull/42', + prTitle: 'feat: test PR', + workItemId: 'card-1', + }); + expect(mockGithubClient.getPR).toHaveBeenCalledWith('acme', 'myapp', 42); + expect(mockGithubClient.getCheckSuiteStatus).toHaveBeenCalledWith('acme', 'myapp', 'sha-123'); + expect(mockClaimReviewDispatch).toHaveBeenCalledWith( + 'acme/myapp:42:sha-123', + 'post-completion-hook', + { + prNumber: 42, + headSha: 'sha-123', + }, + ); + }); + + it('returns null before GitHub lookup when required conditions are missing', async () => { + await expect( + buildPostCompletionReviewDispatch( + { success: false, output: '', error: 'failed', prUrl: SUCCESS_WITH_PR.prUrl }, + PROJECT, + 'card-1', + ), + ).resolves.toBeNull(); + await expect( + buildPostCompletionReviewDispatch( + { success: true, output: '', runId: 'run-1' }, + PROJECT, + 'card-1', + ), + ).resolves.toBeNull(); + await expect( + buildPostCompletionReviewDispatch(SUCCESS_WITH_PR, { ...PROJECT, repo: undefined }, 'card-1'), + ).resolves.toBeNull(); + + expect(mockGithubClient.getPR).not.toHaveBeenCalled(); + }); + + it('returns null when CI is not all passing', async () => { + mockGithubClient.getCheckSuiteStatus.mockResolvedValueOnce({ allPassing: false }); + + const result = await buildPostCompletionReviewDispatch(SUCCESS_WITH_PR, PROJECT, 'card-1'); + + expect(result).toBeNull(); + expect(mockClaimReviewDispatch).not.toHaveBeenCalled(); + expect(mockLogger.debug).toHaveBeenCalledWith( + 'Skipping post-completion review: CI not all passing', + expect.objectContaining({ prNumber: 42, workItemId: 'card-1' }), + ); + }); + + it('returns null when dedup claim fails', async () => { + mockClaimReviewDispatch.mockResolvedValueOnce(false); + + const result = await buildPostCompletionReviewDispatch(SUCCESS_WITH_PR, PROJECT, 'card-1'); + + expect(result).toBeNull(); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Skipping post-completion review: already dispatched', + expect.objectContaining({ dedupKey: 'acme/myapp:42:sha-123' }), + ); + }); + + it('logs and returns null on non-fatal lookup errors', async () => { + mockGithubClient.getCheckSuiteStatus.mockRejectedValueOnce(new Error('GitHub down')); + + const result = await buildPostCompletionReviewDispatch(SUCCESS_WITH_PR, PROJECT, 'card-1'); + + expect(result).toBeNull(); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Post-completion review dispatch failed (non-fatal)', + expect.objectContaining({ error: 'Error: GitHub down' }), + ); + }); +}); diff --git a/tests/unit/triggers/shared/splitting-auto-chain.test.ts b/tests/unit/triggers/shared/splitting-auto-chain.test.ts new file mode 100644 index 000000000..e27ad34dd --- /dev/null +++ b/tests/unit/triggers/shared/splitting-auto-chain.test.ts @@ -0,0 +1,175 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ProjectConfig } from '../../../../src/types/index.js'; + +const { + mockGetPMProvider, + mockResolveProjectPMConfig, + mockHasAutoLabel, + mockCheckTriggerEnabled, + mockIsPipelineAtCapacity, + mockLogger, +} = vi.hoisted(() => ({ + mockGetPMProvider: vi.fn(), + mockResolveProjectPMConfig: vi.fn(), + mockHasAutoLabel: vi.fn(), + mockCheckTriggerEnabled: vi.fn(), + mockIsPipelineAtCapacity: vi.fn(), + mockLogger: { + info: vi.fn(), + warn: vi.fn(), + }, +})); + +vi.mock('../../../../src/pm/context.js', () => ({ + getPMProvider: mockGetPMProvider, +})); + +vi.mock('../../../../src/pm/index.js', () => ({ + resolveProjectPMConfig: mockResolveProjectPMConfig, + hasAutoLabel: (...args: unknown[]) => mockHasAutoLabel(...args), +})); + +vi.mock('../../../../src/triggers/shared/trigger-check.js', () => ({ + checkTriggerEnabled: (...args: unknown[]) => mockCheckTriggerEnabled(...args), +})); + +vi.mock('../../../../src/triggers/shared/backlog-check.js', () => ({ + isPipelineAtCapacity: (...args: unknown[]) => mockIsPipelineAtCapacity(...args), +})); + +vi.mock('../../../../src/utils/logging.js', () => ({ + logger: mockLogger, +})); + +import { buildSplittingAutoChainDispatch } from '../../../../src/triggers/shared/splitting-auto-chain.js'; + +const PROJECT = { + id: 'project-1', + repo: 'acme/myapp', + pm: { type: 'trello' }, +} as ProjectConfig; + +const PM_CONFIG = { + type: 'trello', + labels: { auto: 'label-auto-id' }, +}; + +function setupProvider(overrides: Record = {}) { + const provider = { + getWorkItem: vi.fn().mockResolvedValue({ + id: 'parent-card', + labels: [{ id: 'label-auto-id', name: 'auto' }], + }), + listWorkItems: vi.fn().mockResolvedValue([ + { id: 'backlog-1', labels: [] }, + { id: 'backlog-2', labels: [{ id: 'label-auto-id', name: 'auto' }] }, + ]), + addLabel: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; + mockGetPMProvider.mockReturnValue(provider); + return provider; +} + +describe('buildSplittingAutoChainDispatch', () => { + beforeEach(() => { + vi.clearAllMocks(); + setupProvider(); + mockResolveProjectPMConfig.mockReturnValue(PM_CONFIG); + mockHasAutoLabel.mockImplementation((labels: Array<{ id: string }>) => + labels.some((label) => label.id === 'label-auto-id'), + ); + mockCheckTriggerEnabled.mockResolvedValue(true); + mockIsPipelineAtCapacity.mockResolvedValue({ atCapacity: false, inFlightCount: 0 }); + }); + + it('propagates auto label and returns backlog-manager TriggerResult', async () => { + const provider = setupProvider(); + + const result = await buildSplittingAutoChainDispatch('parent-card', PROJECT); + + expect(result).toEqual({ + agentType: 'backlog-manager', + agentInput: { triggerEvent: 'internal:auto-chain', workItemId: 'parent-card' }, + workItemId: 'parent-card', + }); + expect(provider.listWorkItems).toHaveBeenCalledWith(undefined, { status: 'backlog' }); + expect(provider.addLabel).toHaveBeenCalledWith('backlog-1', 'label-auto-id'); + expect(provider.addLabel).toHaveBeenCalledTimes(1); + expect(mockCheckTriggerEnabled).toHaveBeenCalledWith( + 'project-1', + 'backlog-manager', + 'internal:auto-chain', + 'splitting-auto-propagate', + ); + expect(mockIsPipelineAtCapacity).toHaveBeenCalledWith(PROJECT, provider); + }); + + it('returns null when parent item does not have auto label', async () => { + const provider = setupProvider(); + mockHasAutoLabel.mockReturnValueOnce(false); + + const result = await buildSplittingAutoChainDispatch('parent-card', PROJECT); + + expect(result).toBeNull(); + expect(provider.listWorkItems).not.toHaveBeenCalled(); + expect(mockCheckTriggerEnabled).not.toHaveBeenCalled(); + }); + + it('returns null after label propagation when backlog-manager trigger is disabled', async () => { + const provider = setupProvider(); + mockCheckTriggerEnabled.mockResolvedValueOnce(false); + + const result = await buildSplittingAutoChainDispatch('parent-card', PROJECT); + + expect(result).toBeNull(); + expect(provider.addLabel).toHaveBeenCalledWith('backlog-1', 'label-auto-id'); + expect(mockIsPipelineAtCapacity).not.toHaveBeenCalled(); + }); + + it('returns null when pipeline is at capacity', async () => { + mockIsPipelineAtCapacity.mockResolvedValueOnce({ + atCapacity: true, + reason: 'limit-reached', + inFlightCount: 2, + limit: 2, + availableSlots: 0, + }); + + const result = await buildSplittingAutoChainDispatch('parent-card', PROJECT); + + expect(result).toBeNull(); + expect(mockLogger.info).toHaveBeenCalledWith( + 'propagateAutoLabelAfterSplitting: pipeline at capacity, skipping backlog-manager chain', + expect.objectContaining({ reason: 'limit-reached' }), + ); + }); + + it('logs and returns null when provider listing fails', async () => { + setupProvider({ + listWorkItems: vi.fn().mockRejectedValue(new Error('PM down')), + }); + + const result = await buildSplittingAutoChainDispatch('parent-card', PROJECT); + + expect(result).toBeNull(); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'propagateAutoLabelAfterSplitting: failed to list backlog items', + expect.objectContaining({ error: 'Error: PM down' }), + ); + }); + + it('logs addLabel failures without blocking dispatch', async () => { + setupProvider({ + addLabel: vi.fn().mockRejectedValue(new Error('label failed')), + }); + + const result = await buildSplittingAutoChainDispatch('parent-card', PROJECT); + + expect(result?.agentType).toBe('backlog-manager'); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Failed to add auto label to backlog item', + expect.objectContaining({ itemId: 'backlog-1', error: 'Error: label failed' }), + ); + }); +}); From e05920107c2a3c7b072281646bec3ff7ba5ed965 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Sat, 9 May 2026 10:30:18 +0000 Subject: [PATCH 10/18] fix(cascade-tools): handle false values for non-negatable boolean flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `massageBooleanFlagValues` was rewriting every recognised false value to `--no-`, but oclif only accepts that form for flags declared with `allowNo: true`. `scm create-pr`'s `draft` flag has no `allowNo`, so `--draft false` / `--draft=false` was emitted as `--no-draft` and oclif rejected it as an unknown flag — leaving the PR's promised `--boolFlag true|false` support incomplete for one production command. Fix: `collectBooleanFlagNames` now returns a `Map` where the value tracks `allowNo`. The preprocessor emits `--no-` only when `allowNo` is true; for non-negatable booleans a `false` value simply consumes the token without pushing anything (flag absence = false, which is the correct oclif parse result). Malformed-value error hints are updated to not suggest `--no-` for non-negatable flags. Three new tests added covering the `draft`-style case: - `--draft false` (space-separated) → no unknown-flag error - `--draft=false` (equals-separated) → no unknown-flag error - `--draft true` → still works Co-Authored-By: Claude Sonnet 4.6 --- src/gadgets/shared/cliCommandFactory.ts | 42 ++++++++++++++++------ tests/unit/cli/cli-command-factory.test.ts | 42 ++++++++++++++++++++++ 2 files changed, 73 insertions(+), 11 deletions(-) diff --git a/src/gadgets/shared/cliCommandFactory.ts b/src/gadgets/shared/cliCommandFactory.ts index 129a7267f..90a626f6f 100644 --- a/src/gadgets/shared/cliCommandFactory.ts +++ b/src/gadgets/shared/cliCommandFactory.ts @@ -341,7 +341,7 @@ function normalizeBoolValue(raw: string): boolean | null { // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: argv-shape taxonomy (--key=value, --key value, bare toggle) function massageBooleanFlagValues( argv: readonly string[] | undefined, - booleanFlags: ReadonlySet, + booleanFlags: ReadonlyMap, sink: ErrorSink, ): string[] | undefined { // Pass through `undefined` so oclif's `parse(Cmd)` (no argv arg) keeps @@ -357,6 +357,7 @@ function massageBooleanFlagValues( const eqIdx = tok.indexOf('='); const name = tok.slice(2, eqIdx); if (booleanFlags.has(name)) { + const allowNo = booleanFlags.get(name) ?? false; const value = tok.slice(eqIdx + 1); const normalized = normalizeBoolValue(value); if (normalized === true) { @@ -364,7 +365,10 @@ function massageBooleanFlagValues( continue; } if (normalized === false) { - result.push(`--no-${name}`); + // Only emit --no- when the flag supports negation. For + // non-negatable booleans (e.g. `draft`), omitting the flag + // produces the same `false` result without the unknown-flag error. + if (allowNo) result.push(`--no-${name}`); continue; } emitCliError({ @@ -373,7 +377,9 @@ function massageBooleanFlagValues( message: `Boolean flag --${name} got value '${value}'; accepts true|false|yes|no|1|0`, got: value, expected: 'true|false|yes|no|1|0', - hint: `Use --${name} or --no-${name} for the canonical toggle form, or --${name}=true / --${name}=false.`, + hint: allowNo + ? `Use --${name} or --no-${name} for the canonical toggle form, or --${name}=true / --${name}=false.` + : `Use --${name} for true, or omit the flag for false.`, stdout: sink.stdout, stderr: sink.stderr, exit: sink.exit, @@ -385,6 +391,7 @@ function massageBooleanFlagValues( if (tok.startsWith('--') && !tok.includes('=')) { const name = tok.slice(2); if (booleanFlags.has(name) && i + 1 < argv.length) { + const allowNo = booleanFlags.get(name) ?? false; const next = argv[i + 1]; const normalized = normalizeBoolValue(next); if (normalized === true) { @@ -393,7 +400,9 @@ function massageBooleanFlagValues( continue; } if (normalized === false) { - result.push(`--no-${name}`); + // Only emit --no- when the flag supports negation. For + // non-negatable booleans, just consume the token — absence = false. + if (allowNo) result.push(`--no-${name}`); i++; continue; } @@ -407,7 +416,9 @@ function massageBooleanFlagValues( message: `Boolean flag --${name} got value '${next}'; accepts true|false|yes|no|1|0`, got: next, expected: 'true|false|yes|no|1|0', - hint: `Use --${name} or --no-${name} for the canonical toggle form.`, + hint: allowNo + ? `Use --${name} or --no-${name} for the canonical toggle form.` + : `Use --${name} for true, or omit the flag for false.`, stdout: sink.stdout, stderr: sink.stderr, exit: sink.exit, @@ -422,16 +433,25 @@ function massageBooleanFlagValues( } /** - * Collect the set of boolean flag names declared by a tool definition (used by - * the argv preprocessor to know which flags accept the value form). + * Collect boolean flag metadata for the argv preprocessor. + * + * Returns a Map from flag name to whether it supports `--no-` negation + * (`allowNo`). The preprocessor uses this to decide: + * - `true` value → always rewrite to `--` + * - `false` value → `--no-` only when allowNo is set; otherwise drop the + * token (absence = false for non-negatable booleans, so this + * produces the correct oclif parse result without emitting an + * unknown flag). Fixes `--draft false` on `scm create-pr`. */ -function collectBooleanFlagNames(def: ToolDefinition): Set { - const names = new Set(); +function collectBooleanFlagNames(def: ToolDefinition): Map { + const flags = new Map(); for (const [name, paramDef] of Object.entries(def.parameters)) { if (paramDef.gadgetOnly) continue; - if (paramDef.type === 'boolean') names.add(name); + if (paramDef.type === 'boolean') { + flags.set(name, paramDef.allowNo ?? false); + } } - return names; + return flags; } /** diff --git a/tests/unit/cli/cli-command-factory.test.ts b/tests/unit/cli/cli-command-factory.test.ts index 2e25d7e64..98a117f8d 100644 --- a/tests/unit/cli/cli-command-factory.test.ts +++ b/tests/unit/cli/cli-command-factory.test.ts @@ -248,6 +248,48 @@ describe('cliCommandFactory — flag generation', () => { // oclif rejects 'true' since it's not in the enum options await expect(cmd.run()).rejects.toThrow(); }); + + // Regression for reviewer feedback: `scm create-pr` has a `draft` boolean + // without `allowNo`. Before the fix, `--draft false` was rewritten to + // `--no-draft`, which oclif rejected as an unknown flag. Non-negatable + // booleans must accept `false` by simply omitting the flag (absence = false). + describe('boolean flags without allowNo (draft-style)', () => { + function makeDraftDef() { + return makeToolDef({ + parameters: { + draft: { + type: 'boolean', + describe: 'Create as a draft pull request (default: false)', + optional: true, + }, + }, + }); + } + + it('--draft false (space-separated) produces draft:undefined (falsy), not unknown-flag error', async () => { + const coreFn = vi.fn().mockResolvedValue('ok'); + const Cmd = createCLICommand(makeDraftDef(), coreFn); + const cmd = new Cmd(['--draft', 'false'], makeMockConfig() as never); + await cmd.run(); + expect(coreFn).toHaveBeenCalledWith(expect.not.objectContaining({ draft: true })); + }); + + it('--draft=false (equals-separated) produces draft:undefined (falsy), not unknown-flag error', async () => { + const coreFn = vi.fn().mockResolvedValue('ok'); + const Cmd = createCLICommand(makeDraftDef(), coreFn); + const cmd = new Cmd(['--draft=false'], makeMockConfig() as never); + await cmd.run(); + expect(coreFn).toHaveBeenCalledWith(expect.not.objectContaining({ draft: true })); + }); + + it('--draft true still works for non-negatable booleans', async () => { + const coreFn = vi.fn().mockResolvedValue('ok'); + const Cmd = createCLICommand(makeDraftDef(), coreFn); + const cmd = new Cmd(['--draft', 'true'], makeMockConfig() as never); + await cmd.run(); + expect(coreFn).toHaveBeenCalledWith(expect.objectContaining({ draft: true })); + }); + }); }); it('generates enum flags with restricted options', async () => { From 82ce18a22630b686c16044fd720d5a74d88a7df7 Mon Sep 17 00:00:00 2001 From: aaight Date: Sat, 9 May 2026 12:37:31 +0200 Subject: [PATCH 11/18] refactor(triggers): share PM status and label decisions (#1282) Co-authored-by: Cascade Bot --- src/triggers/jira/label-added.ts | 25 ++--- src/triggers/jira/status-changed.ts | 47 ++++------ src/triggers/linear/label-added.ts | 39 +++----- src/triggers/linear/status-changed.ts | 48 ++++------ src/triggers/shared/pm-label.ts | 55 +++++++++++ src/triggers/shared/pm-status.ts | 91 +++++++++++++++++++ src/triggers/trello/label-added.ts | 22 +---- src/triggers/trello/status-changed.ts | 19 +--- tests/unit/triggers/shared/pm-label.test.ts | 70 ++++++++++++++ tests/unit/triggers/shared/pm-status.test.ts | 83 +++++++++++++++++ tests/unit/triggers/status-changed.test.ts | 1 + .../trigger-event-consistency.test.ts | 9 ++ 12 files changed, 373 insertions(+), 136 deletions(-) create mode 100644 src/triggers/shared/pm-label.ts create mode 100644 src/triggers/shared/pm-status.ts create mode 100644 tests/unit/triggers/shared/pm-label.test.ts create mode 100644 tests/unit/triggers/shared/pm-status.test.ts diff --git a/src/triggers/jira/label-added.ts b/src/triggers/jira/label-added.ts index 895908d7d..66a6b8eff 100644 --- a/src/triggers/jira/label-added.ts +++ b/src/triggers/jira/label-added.ts @@ -14,8 +14,9 @@ import { getJiraConfig } from '../../pm/config.js'; import { resolveProjectPMConfig } from '../../pm/lifecycle.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; +import { buildPMLabelDispatchResult, resolvePMLabelAgentByStatusName } from '../shared/pm-label.js'; import { checkTriggerEnabled } from '../shared/trigger-check.js'; -import { type JiraWebhookPayload, STATUS_TO_AGENT } from './types.js'; +import type { JiraWebhookPayload } from './types.js'; /** * Parse which labels were added from a JIRA label changelog item. @@ -84,14 +85,10 @@ export class JiraReadyToProcessLabelTrigger implements TriggerHandler { return null; } - // Invert the statuses mapping: find which CASCADE status key maps to this JIRA status - let agentType: string | undefined; - for (const [cascadeStatus, jiraStatus] of Object.entries(jiraConfig.statuses)) { - if (jiraStatus.toLowerCase() === currentStatus.toLowerCase()) { - agentType = STATUS_TO_AGENT[cascadeStatus]; - break; - } - } + const agentType = resolvePMLabelAgentByStatusName({ + statusName: currentStatus, + configuredStatuses: jiraConfig.statuses, + }); if (!agentType) { logger.debug('JIRA issue status does not map to any agent', { @@ -117,17 +114,11 @@ export class JiraReadyToProcessLabelTrigger implements TriggerHandler { const workItemUrl = `${jiraConfig.baseUrl}/browse/${issueKey}`; const workItemTitle = payload.issue?.fields?.summary ?? undefined; - return { + return buildPMLabelDispatchResult({ agentType, - agentInput: { - workItemId: issueKey, - workItemUrl, - workItemTitle, - triggerEvent: 'pm:label-added', - }, workItemId: issueKey, workItemUrl, workItemTitle, - }; + }); } } diff --git a/src/triggers/jira/status-changed.ts b/src/triggers/jira/status-changed.ts index 0bbd72aab..79e995fa6 100644 --- a/src/triggers/jira/status-changed.ts +++ b/src/triggers/jira/status-changed.ts @@ -13,8 +13,13 @@ import { getJiraConfig } from '../../pm/config.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; import { shouldBlockForPipelineCapacity } from '../shared/pipeline-capacity-gate.js'; +import { + buildPMStatusDispatchResult, + resolvePMStatusAgentByName, + shouldFirePMStatusEvent, +} from '../shared/pm-status.js'; import { checkTriggerEnabledWithParams } from '../shared/trigger-check.js'; -import { type JiraWebhookPayload, STATUS_TO_AGENT } from './types.js'; +import type { JiraWebhookPayload } from './types.js'; function isCreateEvent(payload: JiraWebhookPayload): boolean { return payload.webhookEvent === 'jira:issue_created'; @@ -37,24 +42,6 @@ function resolveNewStatus(payload: JiraWebhookPayload): string | undefined { return findStatusChange(payload)?.toString; } -function resolveAgentType( - newStatus: string, - configStatuses: Record, -): string | undefined { - const lower = newStatus.toLowerCase(); - for (const [cascadeStatus, jiraStatus] of Object.entries(configStatuses)) { - if (jiraStatus.toLowerCase() === lower) { - return STATUS_TO_AGENT[cascadeStatus]; - } - } - return undefined; -} - -function shouldFireOnEvent(isCreate: boolean, parameters: Record): boolean { - if (isCreate) return parameters.onCreate === true; - return parameters.onMove !== false; // default true -} - export class JiraStatusChangedTrigger implements TriggerHandler { name = 'jira-status-changed'; description = 'Triggers agent when a JIRA issue transitions to a configured status'; @@ -96,8 +83,11 @@ export class JiraStatusChangedTrigger implements TriggerHandler { return null; } - const agentType = resolveAgentType(newStatus, jiraConfig.statuses); - if (!agentType) { + const resolved = resolvePMStatusAgentByName({ + statusName: newStatus, + configuredStatuses: jiraConfig.statuses, + }); + if (!resolved) { logger.debug('JIRA status transition does not map to any agent', { issueKey, newStatus, @@ -105,6 +95,7 @@ export class JiraStatusChangedTrigger implements TriggerHandler { }); return null; } + const { agentType } = resolved; const { enabled, parameters } = await checkTriggerEnabledWithParams( ctx.project.id, @@ -115,7 +106,7 @@ export class JiraStatusChangedTrigger implements TriggerHandler { if (!enabled) return null; const isCreate = isCreateEvent(payload); - if (!shouldFireOnEvent(isCreate, parameters)) { + if (!shouldFirePMStatusEvent(isCreate, parameters)) { logger.debug('JIRA status-changed event gated by trigger params', { issueKey, agentType, @@ -148,18 +139,12 @@ export class JiraStatusChangedTrigger implements TriggerHandler { const workItemUrl = `${jiraConfig.baseUrl}/browse/${issueKey}`; const workItemTitle = payload.issue?.fields?.summary ?? undefined; - return { + return buildPMStatusDispatchResult({ + projectId: ctx.project.id, agentType, - agentInput: { - workItemId: issueKey, - workItemUrl, - workItemTitle, - triggerEvent: 'pm:status-changed', - }, workItemId: issueKey, workItemUrl, workItemTitle, - coalesceKey: `${ctx.project.id}:${issueKey}`, - }; + }); } } diff --git a/src/triggers/linear/label-added.ts b/src/triggers/linear/label-added.ts index adb18e3fd..caa567dbc 100644 --- a/src/triggers/linear/label-added.ts +++ b/src/triggers/linear/label-added.ts @@ -17,12 +17,9 @@ import { getLinearConfig } from '../../pm/config.js'; import { resolveProjectPMConfig } from '../../pm/lifecycle.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; +import { buildPMLabelDispatchResult, resolvePMLabelAgentByStatusId } from '../shared/pm-label.js'; import { checkTriggerEnabled } from '../shared/trigger-check.js'; -import { - type LinearWebhookIssueLabelData, - type LinearWebhookTriggerPayload, - STATUS_TO_AGENT, -} from './types.js'; +import type { LinearWebhookIssueLabelData, LinearWebhookTriggerPayload } from './types.js'; export class LinearReadyToProcessLabelTrigger implements TriggerHandler { name = 'linear-ready-to-process-label-added'; @@ -76,18 +73,11 @@ export class LinearReadyToProcessLabelTrigger implements TriggerHandler { return null; } - // Find which CASCADE status key maps to this Linear state ID - let agentType: string | undefined; - let matchedCascadeStatus: string | undefined; - for (const [cascadeStatus, linearStateId] of Object.entries(linearConfig.statuses)) { - if (linearStateId === issueStateId) { - agentType = STATUS_TO_AGENT[cascadeStatus]; - matchedCascadeStatus = cascadeStatus; - break; - } - } - - if (!agentType) { + const resolved = resolvePMLabelAgentByStatusId({ + statusId: issueStateId, + configuredStatuses: linearConfig.statuses, + }); + if (!resolved) { logger.debug('Linear issue state does not map to any agent', { issueIdentifier, issueStateId, @@ -95,6 +85,7 @@ export class LinearReadyToProcessLabelTrigger implements TriggerHandler { }); return null; } + const { agentType, cascadeStatus: matchedCascadeStatus } = resolved; // Check per-agent ready-to-process toggle via DB-driven system if (!(await checkTriggerEnabled(ctx.project.id, agentType, 'pm:label-added', this.name))) { @@ -113,18 +104,14 @@ export class LinearReadyToProcessLabelTrigger implements TriggerHandler { // Issue title is not included in IssueLabel webhook data const workItemTitle: string | undefined = undefined; - return { + return buildPMLabelDispatchResult({ agentType, - agentInput: { - workItemId, - workItemUrl, - workItemTitle, - triggerEvent: 'pm:label-added', - linearIssueId: issueId, - }, workItemId, workItemUrl, workItemTitle, - }; + agentInput: { + linearIssueId: issueId, + }, + }); } } diff --git a/src/triggers/linear/status-changed.ts b/src/triggers/linear/status-changed.ts index 26af52f58..b52badec8 100644 --- a/src/triggers/linear/status-changed.ts +++ b/src/triggers/linear/status-changed.ts @@ -17,26 +17,13 @@ import { getLinearConfig } from '../../pm/config.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; import { shouldBlockForPipelineCapacity } from '../shared/pipeline-capacity-gate.js'; +import { + buildPMStatusDispatchResult, + resolvePMStatusAgentById, + shouldFirePMStatusEvent, +} from '../shared/pm-status.js'; import { checkTriggerEnabledWithParams } from '../shared/trigger-check.js'; -import { type LinearWebhookTriggerPayload, STATUS_TO_AGENT } from './types.js'; - -function resolveAgentType( - newStateId: string, - configStatuses: Record, -): { agentType: string; cascadeStatus: string } | undefined { - for (const [cascadeStatus, linearStateId] of Object.entries(configStatuses)) { - if (linearStateId === newStateId) { - const agentType = STATUS_TO_AGENT[cascadeStatus]; - if (agentType) return { agentType, cascadeStatus }; - } - } - return undefined; -} - -function shouldFireOnEvent(isCreate: boolean, parameters: Record): boolean { - if (isCreate) return parameters.onCreate === true; - return parameters.onMove !== false; // default true -} +import type { LinearWebhookTriggerPayload } from './types.js'; export class LinearStatusChangedTrigger implements TriggerHandler { name = 'linear-status-changed'; @@ -85,7 +72,10 @@ export class LinearStatusChangedTrigger implements TriggerHandler { return null; } - const resolved = resolveAgentType(newStateId, linearConfig.statuses); + const resolved = resolvePMStatusAgentById({ + statusId: newStateId, + configuredStatuses: linearConfig.statuses, + }); if (!resolved) { logger.debug('Linear state transition does not map to any agent', { issueIdentifier, @@ -105,7 +95,7 @@ export class LinearStatusChangedTrigger implements TriggerHandler { if (!enabled) return null; const isCreate = payload.action === 'create'; - if (!shouldFireOnEvent(isCreate, parameters)) { + if (!shouldFirePMStatusEvent(isCreate, parameters)) { logger.debug('Linear status-changed event gated by trigger params', { issueIdentifier, agentType, @@ -139,19 +129,15 @@ export class LinearStatusChangedTrigger implements TriggerHandler { const workItemUrl = issueUrl; const workItemTitle = issueTitle; - return { + return buildPMStatusDispatchResult({ + projectId: ctx.project.id, agentType, - agentInput: { - workItemId, - workItemUrl, - workItemTitle, - triggerEvent: 'pm:status-changed', - linearIssueId: issueId, - }, workItemId, workItemUrl, workItemTitle, - coalesceKey: `${ctx.project.id}:${workItemId}`, - }; + agentInput: { + linearIssueId: issueId, + }, + }); } } diff --git a/src/triggers/shared/pm-label.ts b/src/triggers/shared/pm-label.ts new file mode 100644 index 000000000..0d89edf39 --- /dev/null +++ b/src/triggers/shared/pm-label.ts @@ -0,0 +1,55 @@ +import type { AgentInput, TriggerResult } from '../../types/index.js'; +import { TRIGGER_EVENTS } from './events.js'; +import { resolvePMStatusAgentById, resolvePMStatusAgentByName } from './pm-status.js'; +import { buildPMDispatchResult } from './result-builders.js'; + +export function resolvePMLabelAgentByList(args: { + currentListId: string; + lists: Partial>; +}): string | undefined { + if (args.currentListId === args.lists.splitting) return 'splitting'; + if (args.currentListId === args.lists.planning) return 'planning'; + if (args.currentListId === args.lists.todo) return 'implementation'; + return undefined; +} + +export function resolvePMLabelAgentByStatusName(args: { + statusName: string; + configuredStatuses: Record; +}): string | undefined { + return resolvePMStatusAgentByName({ + statusName: args.statusName, + configuredStatuses: args.configuredStatuses, + })?.agentType; +} + +export function resolvePMLabelAgentByStatusId(args: { + statusId: string; + configuredStatuses: Record; +}): { agentType: string; cascadeStatus: string } | undefined { + return resolvePMStatusAgentById({ + statusId: args.statusId, + configuredStatuses: args.configuredStatuses, + }); +} + +export function buildPMLabelDispatchResult(args: { + agentType: string; + workItemId: string; + workItemUrl?: string; + workItemTitle?: string; + agentInput?: AgentInput; +}): TriggerResult { + return buildPMDispatchResult({ + agentType: args.agentType, + triggerEvent: TRIGGER_EVENTS.PM.LABEL_ADDED, + workItemId: args.workItemId, + workItemUrl: args.workItemUrl, + workItemTitle: args.workItemTitle, + agentInput: { + workItemUrl: args.workItemUrl, + workItemTitle: args.workItemTitle, + ...args.agentInput, + }, + }); +} diff --git a/src/triggers/shared/pm-status.ts b/src/triggers/shared/pm-status.ts new file mode 100644 index 000000000..8045ad052 --- /dev/null +++ b/src/triggers/shared/pm-status.ts @@ -0,0 +1,91 @@ +import type { AgentInput, TriggerResult } from '../../types/index.js'; +import { TRIGGER_EVENTS } from './events.js'; +import { buildPMDispatchResult } from './result-builders.js'; +import { STATUS_TO_AGENT } from './status-to-agent.js'; + +export interface ResolvedPMStatusAgent { + agentType: string; + cascadeStatus: string; +} + +type StatusMatcher = (configuredStatus: string, incomingStatus: string) => boolean; + +const exactStatusMatcher: StatusMatcher = (configuredStatus, incomingStatus) => + configuredStatus === incomingStatus; + +const caseInsensitiveStatusMatcher: StatusMatcher = (configuredStatus, incomingStatus) => + configuredStatus.toLowerCase() === incomingStatus.toLowerCase(); + +export function shouldFirePMStatusEvent( + isCreate: boolean, + parameters: Record, +): boolean { + if (isCreate) return parameters.onCreate === true; + return parameters.onMove !== false; +} + +export function resolvePMStatusAgent(args: { + incomingStatus: string; + configuredStatuses: Record; + matcher?: StatusMatcher; +}): ResolvedPMStatusAgent | undefined { + const matcher = args.matcher ?? exactStatusMatcher; + + for (const [cascadeStatus, configuredStatus] of Object.entries(args.configuredStatuses)) { + if (matcher(configuredStatus, args.incomingStatus)) { + const agentType = STATUS_TO_AGENT[cascadeStatus]; + if (agentType) return { agentType, cascadeStatus }; + } + } + + return undefined; +} + +export function resolvePMStatusAgentByName(args: { + statusName: string; + configuredStatuses: Record; +}): ResolvedPMStatusAgent | undefined { + return resolvePMStatusAgent({ + incomingStatus: args.statusName, + configuredStatuses: args.configuredStatuses, + matcher: caseInsensitiveStatusMatcher, + }); +} + +export function resolvePMStatusAgentById(args: { + statusId: string; + configuredStatuses: Record; +}): ResolvedPMStatusAgent | undefined { + return resolvePMStatusAgent({ + incomingStatus: args.statusId, + configuredStatuses: args.configuredStatuses, + matcher: exactStatusMatcher, + }); +} + +export function buildPMStatusCoalesceKey(projectId: string, workItemId: string): string { + return `${projectId}:${workItemId}`; +} + +export function buildPMStatusDispatchResult(args: { + projectId: string; + agentType: string; + workItemId: string; + workItemUrl?: string; + workItemTitle?: string; + agentInput?: AgentInput; +}): TriggerResult { + return buildPMDispatchResult({ + agentType: args.agentType, + triggerEvent: TRIGGER_EVENTS.PM.STATUS_CHANGED, + workItemId: args.workItemId, + workItemUrl: args.workItemUrl, + workItemTitle: args.workItemTitle, + agentInput: { + workItemUrl: args.workItemUrl, + workItemTitle: args.workItemTitle, + ...args.agentInput, + }, + coalesceKey: buildPMStatusCoalesceKey(args.projectId, args.workItemId), + }); +} diff --git a/src/triggers/trello/label-added.ts b/src/triggers/trello/label-added.ts index dace3ccef..4ff5911f0 100644 --- a/src/triggers/trello/label-added.ts +++ b/src/triggers/trello/label-added.ts @@ -1,6 +1,7 @@ import { getTrelloConfig } from '../../pm/config.js'; import { trelloClient } from '../../trello/client.js'; import { logger } from '../../utils/logging.js'; +import { buildPMLabelDispatchResult, resolvePMLabelAgentByList } from '../shared/pm-label.js'; import { checkTriggerEnabled } from '../shared/trigger-check.js'; import type { TrelloWebhookPayload, @@ -44,15 +45,8 @@ export class ReadyToProcessLabelTrigger implements TriggerHandler { // Determine agent type based on current list const lists = getTrelloConfig(ctx.project)?.lists ?? {}; - let agentType: string; - - if (currentListId === lists.splitting) { - agentType = 'splitting'; - } else if (currentListId === lists.planning) { - agentType = 'planning'; - } else if (currentListId === lists.todo) { - agentType = 'implementation'; - } else { + const agentType = resolvePMLabelAgentByList({ currentListId, lists }); + if (!agentType) { logger.info('Card not in a trigger-eligible list, skipping ready-to-process label', { currentListId, lists, @@ -72,17 +66,11 @@ export class ReadyToProcessLabelTrigger implements TriggerHandler { const workItemUrl = card.shortUrl || undefined; const workItemTitle = card.name || undefined; - return { + return buildPMLabelDispatchResult({ agentType, - agentInput: { - workItemId: cardId, - workItemUrl, - workItemTitle, - triggerEvent: 'pm:label-added', - }, workItemId: cardId, workItemUrl, workItemTitle, - }; + }); } } diff --git a/src/triggers/trello/status-changed.ts b/src/triggers/trello/status-changed.ts index 7172c202a..ea29d516c 100644 --- a/src/triggers/trello/status-changed.ts +++ b/src/triggers/trello/status-changed.ts @@ -2,6 +2,7 @@ import { getTrelloConfig } from '../../pm/config.js'; import { invalidateSnapshot } from '../../router/snapshot-manager.js'; import { logger } from '../../utils/logging.js'; import { shouldBlockForPipelineCapacity } from '../shared/pipeline-capacity-gate.js'; +import { buildPMStatusDispatchResult, shouldFirePMStatusEvent } from '../shared/pm-status.js'; import { checkTriggerEnabledWithParams } from '../shared/trigger-check.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../types.js'; import { isTrelloWebhookPayload, type TrelloWebhookPayload } from './types.js'; @@ -26,11 +27,6 @@ interface StatusChangedConfig { invalidateSnapshotOnMove?: boolean; } -function shouldFireOnEvent(isCreate: boolean, parameters: Record): boolean { - if (isCreate) return parameters.onCreate === true; - return parameters.onMove !== false; // default true -} - function createStatusChangedTrigger(config: StatusChangedConfig): TriggerHandler { return { name: config.name, @@ -69,7 +65,7 @@ function createStatusChangedTrigger(config: StatusChangedConfig): TriggerHandler const payload = ctx.payload as TrelloWebhookPayload; const isCreate = payload.action.type === 'createCard'; - if (!shouldFireOnEvent(isCreate, parameters)) { + if (!shouldFirePMStatusEvent(isCreate, parameters)) { logger.debug('Trello status-changed event gated by trigger params', { trigger: config.name, eventKind: isCreate ? 'create' : 'move', @@ -108,18 +104,13 @@ function createStatusChangedTrigger(config: StatusChangedConfig): TriggerHandler invalidateSnapshot(ctx.project.id, cardId); } - return { + return buildPMStatusDispatchResult({ + projectId: ctx.project.id, agentType: config.agentType, - agentInput: { - workItemId: cardId, - workItemUrl, - workItemTitle, - triggerEvent: 'pm:status-changed', - }, workItemId: cardId, workItemUrl, workItemTitle, - }; + }); }, }; } diff --git a/tests/unit/triggers/shared/pm-label.test.ts b/tests/unit/triggers/shared/pm-label.test.ts new file mode 100644 index 000000000..2b80fbcf7 --- /dev/null +++ b/tests/unit/triggers/shared/pm-label.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { + buildPMLabelDispatchResult, + resolvePMLabelAgentByList, + resolvePMLabelAgentByStatusId, + resolvePMLabelAgentByStatusName, +} from '../../../../src/triggers/shared/pm-label.js'; + +describe('PM label helpers', () => { + it('resolves Trello current lists to agent types', () => { + const lists = { + splitting: 'list-splitting', + planning: 'list-planning', + todo: 'list-todo', + }; + + expect(resolvePMLabelAgentByList({ currentListId: 'list-splitting', lists })).toBe('splitting'); + expect(resolvePMLabelAgentByList({ currentListId: 'list-planning', lists })).toBe('planning'); + expect(resolvePMLabelAgentByList({ currentListId: 'list-todo', lists })).toBe('implementation'); + expect(resolvePMLabelAgentByList({ currentListId: 'list-backlog', lists })).toBeUndefined(); + }); + + it('resolves JIRA status names to label-trigger agent types', () => { + expect( + resolvePMLabelAgentByStatusName({ + statusName: 'planning', + configuredStatuses: { + planning: 'Planning', + }, + }), + ).toBe('planning'); + }); + + it('resolves Linear state IDs to label-trigger agent types and matched cascade status', () => { + expect( + resolvePMLabelAgentByStatusId({ + statusId: 'state-todo', + configuredStatuses: { + todo: 'state-todo', + }, + }), + ).toEqual({ agentType: 'implementation', cascadeStatus: 'todo' }); + }); + + it('builds canonical label-added dispatch results', () => { + expect( + buildPMLabelDispatchResult({ + agentType: 'implementation', + workItemId: 'CARD-123', + workItemUrl: 'https://example.test/CARD-123', + workItemTitle: 'Implement feature', + agentInput: { linearIssueId: 'linear-issue-id' }, + }), + ).toEqual({ + agentType: 'implementation', + agentInput: { + workItemId: 'CARD-123', + workItemUrl: 'https://example.test/CARD-123', + workItemTitle: 'Implement feature', + triggerEvent: 'pm:label-added', + linearIssueId: 'linear-issue-id', + }, + workItemId: 'CARD-123', + workItemUrl: 'https://example.test/CARD-123', + workItemTitle: 'Implement feature', + onBlocked: undefined, + coalesceKey: undefined, + }); + }); +}); diff --git a/tests/unit/triggers/shared/pm-status.test.ts b/tests/unit/triggers/shared/pm-status.test.ts new file mode 100644 index 000000000..3d9c1f497 --- /dev/null +++ b/tests/unit/triggers/shared/pm-status.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { + buildPMStatusCoalesceKey, + buildPMStatusDispatchResult, + resolvePMStatusAgentById, + resolvePMStatusAgentByName, + shouldFirePMStatusEvent, +} from '../../../../src/triggers/shared/pm-status.js'; + +describe('PM status helpers', () => { + it('resolves provider status names to agent types case-insensitively', () => { + expect( + resolvePMStatusAgentByName({ + statusName: 'to do', + configuredStatuses: { + splitting: 'Splitting', + todo: 'To Do', + }, + }), + ).toEqual({ agentType: 'implementation', cascadeStatus: 'todo' }); + }); + + it('resolves provider status IDs to agent types exactly', () => { + expect( + resolvePMStatusAgentById({ + statusId: 'state-planning', + configuredStatuses: { + planning: 'state-planning', + todo: 'state-todo', + }, + }), + ).toEqual({ agentType: 'planning', cascadeStatus: 'planning' }); + }); + + it('ignores configured statuses without an agent mapping', () => { + expect( + resolvePMStatusAgentByName({ + statusName: 'Done', + configuredStatuses: { + merged: 'Done', + }, + }), + ).toBeUndefined(); + }); + + it('applies shared onCreate/onMove trigger parameter semantics', () => { + expect(shouldFirePMStatusEvent(true, { onCreate: true })).toBe(true); + expect(shouldFirePMStatusEvent(true, {})).toBe(false); + expect(shouldFirePMStatusEvent(false, {})).toBe(true); + expect(shouldFirePMStatusEvent(false, { onMove: false })).toBe(false); + }); + + it('builds project-scoped coalesce keys', () => { + expect(buildPMStatusCoalesceKey('project-1', 'CARD-123')).toBe('project-1:CARD-123'); + }); + + it('builds canonical status-changed dispatch results', () => { + expect( + buildPMStatusDispatchResult({ + projectId: 'project-1', + agentType: 'implementation', + workItemId: 'CARD-123', + workItemUrl: 'https://example.test/CARD-123', + workItemTitle: 'Implement feature', + agentInput: { linearIssueId: 'linear-issue-id' }, + }), + ).toEqual({ + agentType: 'implementation', + agentInput: { + workItemId: 'CARD-123', + workItemUrl: 'https://example.test/CARD-123', + workItemTitle: 'Implement feature', + triggerEvent: 'pm:status-changed', + linearIssueId: 'linear-issue-id', + }, + workItemId: 'CARD-123', + workItemUrl: 'https://example.test/CARD-123', + workItemTitle: 'Implement feature', + onBlocked: undefined, + coalesceKey: 'project-1:CARD-123', + }); + }); +}); diff --git a/tests/unit/triggers/status-changed.test.ts b/tests/unit/triggers/status-changed.test.ts index f4104f53f..2e7386269 100644 --- a/tests/unit/triggers/status-changed.test.ts +++ b/tests/unit/triggers/status-changed.test.ts @@ -198,6 +198,7 @@ describe('TrelloStatusChangedSplittingTrigger', () => { expect(result?.workItemId).toBe('card123'); expect(result?.agentInput.workItemId).toBe('card123'); expect(result?.agentInput.triggerEvent).toBe('pm:status-changed'); + expect(result?.coalesceKey).toBe('test:card123'); }); it('populates workItemUrl and workItemTitle from payload card data', async () => { diff --git a/tests/unit/triggers/trigger-event-consistency.test.ts b/tests/unit/triggers/trigger-event-consistency.test.ts index ed1c925da..5e87374b1 100644 --- a/tests/unit/triggers/trigger-event-consistency.test.ts +++ b/tests/unit/triggers/trigger-event-consistency.test.ts @@ -109,6 +109,15 @@ function scanHandler(file: string): HandlerScan { emittedEvents.add(m[1]); } + // PM status/label handlers intentionally centralize result assembly in shared + // builders. Treat those builder calls as local emissions for this drift guard. + if (src.includes('buildPMStatusDispatchResult')) { + emittedEvents.add('pm:status-changed'); + } + if (src.includes('buildPMLabelDispatchResult')) { + emittedEvents.add('pm:label-added'); + } + return { file, gatingEvents, emittedEvents }; } From 0befb548cb417976f87eaec2586a97d36ec3e2d2 Mon Sep 17 00:00:00 2001 From: aaight Date: Sat, 9 May 2026 13:00:38 +0200 Subject: [PATCH 12/18] refactor(github): extract CI dispatch decisions (#1283) Co-authored-by: Cascade Bot --- src/triggers/github/check-suite-decision.ts | 138 ++++++++++++++ src/triggers/github/check-suite-failure.ts | 98 ++++------ src/triggers/github/check-suite-success.ts | 132 ++++++-------- src/triggers/github/pr-conflict-detected.ts | 37 ++-- src/triggers/github/pr-resolution.ts | 56 ++++++ src/triggers/github/respond-to-ci-dispatch.ts | 18 +- src/triggers/github/result-builders.ts | 72 ++++++++ .../github/check-suite-decision.test.ts | 170 ++++++++++++++++++ .../trigger-event-consistency.test.ts | 9 + 9 files changed, 553 insertions(+), 177 deletions(-) create mode 100644 src/triggers/github/check-suite-decision.ts create mode 100644 src/triggers/github/pr-resolution.ts create mode 100644 src/triggers/github/result-builders.ts create mode 100644 tests/unit/triggers/github/check-suite-decision.test.ts diff --git a/src/triggers/github/check-suite-decision.ts b/src/triggers/github/check-suite-decision.ts new file mode 100644 index 000000000..163690e5b --- /dev/null +++ b/src/triggers/github/check-suite-decision.ts @@ -0,0 +1,138 @@ +import type { CheckSuiteStatus } from '../../github/client.js'; +import { isCascadeBot, type PersonaIdentities } from '../../github/personas.js'; +import type { ProjectConfig } from '../../types/index.js'; + +export type CheckSuiteDecision = + | { action: 'defer'; incompleteChecks: string[]; message: string } + | { action: 'respond-to-ci' } + | { action: 'review' } + | { action: 'skip'; message: string }; + +export type CheckSuiteDecisionMode = + | { kind: 'review'; parameters: Record } + | { kind: 'respond-to-ci' }; + +export interface DecideCheckSuiteOutcomeOptions { + prNumber: number; + prAuthorLogin: string; + prBaseRef: string; + project: ProjectConfig; + personaIdentities: PersonaIdentities | undefined; + handlerName: string; + mode: CheckSuiteDecisionMode; +} + +export interface DecideCheckSuiteAggregateOptions extends DecideCheckSuiteOutcomeOptions { + checkStatus: CheckSuiteStatus; +} + +const FAILURE_CONCLUSIONS = new Set(['failure', 'timed_out', 'action_required']); +const VALID_AUTHOR_MODES = new Set(['own', 'external', 'all']); + +function resolveAuthorMode(parameters: Record): string { + const rawMode = parameters.authorMode; + return typeof rawMode === 'string' && VALID_AUTHOR_MODES.has(rawMode) ? rawMode : 'own'; +} + +function authorModeDecision( + prAuthorLogin: string, + personaIdentities: PersonaIdentities | undefined, + parameters: Record, + prNumber: number, +): Extract | null { + if (!personaIdentities) { + return { + action: 'skip', + message: 'Cascade persona identities could not be resolved (token / GitHub API issue)', + }; + } + + const authorMode = resolveAuthorMode(parameters); + const isCascadePR = isCascadeBot(prAuthorLogin, personaIdentities); + const shouldTrigger = + authorMode === 'all' || + (authorMode === 'own' && isCascadePR) || + (authorMode === 'external' && !isCascadePR); + + if (shouldTrigger) return null; + + return { + action: 'skip', + message: `PR #${prNumber} author ${prAuthorLogin} does not match configured authorMode '${authorMode}' (isCascadePR=${isCascadePR})`, + }; +} + +function cascadePersonaDecision( + prAuthorLogin: string, + personaIdentities: PersonaIdentities | undefined, + prNumber: number, +): Extract | null { + if (!personaIdentities) { + return { + action: 'skip', + message: 'Cascade persona identities could not be resolved (token / GitHub API issue)', + }; + } + if (isCascadeBot(prAuthorLogin, personaIdentities)) return null; + return { + action: 'skip', + message: `PR #${prNumber} not authored by a cascade persona (author: ${prAuthorLogin})`, + }; +} + +export function decideCheckSuiteGates( + options: DecideCheckSuiteOutcomeOptions, +): Extract | null { + const { prNumber, prAuthorLogin, prBaseRef, project, personaIdentities, mode } = options; + + const authorSkip = + mode.kind === 'review' + ? authorModeDecision(prAuthorLogin, personaIdentities, mode.parameters, prNumber) + : cascadePersonaDecision(prAuthorLogin, personaIdentities, prNumber); + if (authorSkip) return authorSkip; + + if (prBaseRef !== project.baseBranch) { + return { + action: 'skip', + message: `PR #${prNumber} targets ${prBaseRef}, not project base branch ${project.baseBranch}`, + }; + } + + return null; +} + +export function decideCheckSuiteOutcome( + options: DecideCheckSuiteAggregateOptions, +): CheckSuiteDecision { + const { checkStatus, prNumber, mode } = options; + + const gateSkip = decideCheckSuiteGates(options); + if (gateSkip) return gateSkip; + + const incompleteChecks = checkStatus.checkRuns + .filter((cr) => cr.status !== 'completed') + .map((cr) => cr.name); + if (incompleteChecks.length > 0) { + return { + action: 'defer', + incompleteChecks, + message: `Not all checks complete yet (${incompleteChecks.length}/${checkStatus.totalCount} still running): ${incompleteChecks.join(', ')}`, + }; + } + + const anyFailed = checkStatus.checkRuns.some( + (cr) => cr.conclusion !== null && FAILURE_CONCLUSIONS.has(cr.conclusion), + ); + if (anyFailed) { + return { action: 'respond-to-ci' }; + } + + if (mode.kind === 'respond-to-ci') { + return { + action: 'skip', + message: `All ${checkStatus.totalCount} checks passed for PR #${prNumber} — no action needed`, + }; + } + + return { action: 'review' }; +} diff --git a/src/triggers/github/check-suite-failure.ts b/src/triggers/github/check-suite-failure.ts index 28d7ad2d2..8c3dd152b 100644 --- a/src/triggers/github/check-suite-failure.ts +++ b/src/triggers/github/check-suite-failure.ts @@ -9,44 +9,14 @@ import { requirePersonaIdentities, } from '../shared/gates.js'; import { skip } from '../shared/skip.js'; +import { decideCheckSuiteOutcome } from './check-suite-decision.js'; +import { resolveCheckSuitePRNumber } from './pr-resolution.js'; import { dispatchRespondToCi, resetFixAttempts } from './respond-to-ci-dispatch.js'; import { type GitHubCheckSuitePayload, isGitHubCheckSuitePayload } from './types.js'; -import { parsePrNumberFromRef, resolveWorkItemDisplayData, resolveWorkItemId } from './utils.js'; +import { resolveWorkItemDisplayData, resolveWorkItemId } from './utils.js'; export { resetFixAttempts }; -/** - * Resolve a PR number from a check_suite payload. - * Tries pull_requests[], then refs/pull/{N}/head parsing, then a GitHub API lookup by branch. - */ -async function resolvePrNumber( - owner: string, - repo: string, - pullRequests: Array<{ number: number }>, - headBranch: string | null | undefined, - handlerName: string, -): Promise { - if (pullRequests.length > 0) return pullRequests[0].number; - - const parsed = parsePrNumberFromRef(headBranch); - if (parsed !== null) return parsed; - - // GitHub omits pull_requests for some check suites (e.g. CodeQL). - // Fall back to looking up the open PR by branch name. - if (!headBranch) { - logger.info('No pull_requests and no head_branch in payload, skipping', { - handler: handlerName, - }); - return null; - } - const pr = await githubClient.getOpenPRByBranch(owner, repo, headBranch); - if (!pr) { - logger.info('No open PR found for head branch, skipping', { handler: handlerName, headBranch }); - return null; - } - return pr.number; -} - export class CheckSuiteFailureTrigger implements TriggerHandler { name = 'check-suite-failure'; description = @@ -82,24 +52,23 @@ export class CheckSuiteFailureTrigger implements TriggerHandler { const { owner, repo } = parseRepoFullName(payload.repository.full_name); // Resolve PR number — from payload, refs/pull/{N}/head, or branch name lookup - const prNumber = await resolvePrNumber( + const prResolution = await resolveCheckSuitePRNumber({ owner, repo, - payload.check_suite.pull_requests, - payload.check_suite.head_branch, - this.name, - ); - if (prNumber === null) { + pullRequests: payload.check_suite.pull_requests, + headBranch: payload.check_suite.head_branch, + handlerName: this.name, + lookupOpenPRByBranch: githubClient.getOpenPRByBranch, + }); + if (!prResolution.ok) { return skip(this.name, 'Could not resolve PR number from check_suite payload'); } + const prNumber = prResolution.prNumber; const headSha = payload.check_suite.head_sha; // Fetch PR details const prDetails = await githubClient.getPR(owner, repo, prNumber); - // Sync gate chain — author must be a cascade persona (implementer OR - // reviewer; loop-prevention) AND the PR must target the project's base - // branch. Both gates short-circuit on the first failure. const personasResult = requirePersonaIdentities(ctx.personaIdentities, prNumber, this.name); if (!personasResult.ok) return personasResult.skip; @@ -112,35 +81,36 @@ export class CheckSuiteFailureTrigger implements TriggerHandler { const workItemId = await resolveWorkItemId(ctx.project.id, prNumber); const { workItemUrl, workItemTitle } = await resolveWorkItemDisplayData(workItemId); - // Get ALL check runs for this commit to verify they're all complete const checkStatus = await githubClient.getCheckSuiteStatus(owner, repo, headSha); + const decision = decideCheckSuiteOutcome({ + checkStatus, + prNumber, + prAuthorLogin: prDetails.user.login, + prBaseRef: prDetails.baseRef, + project: ctx.project, + personaIdentities: ctx.personaIdentities, + handlerName: this.name, + mode: { kind: 'respond-to-ci' }, + }); - // Verify ALL checks have completed (not still running) - const allComplete = checkStatus.checkRuns.every((cr) => cr.status === 'completed'); - if (!allComplete) { - const incomplete = checkStatus.checkRuns - .filter((cr) => cr.status !== 'completed') - .map((cr) => cr.name); + if (decision.action === 'defer') { logger.info('Not all checks complete yet, waiting', { prNumber, totalChecks: checkStatus.totalCount, - incompleteChecks: incomplete, + incompleteChecks: decision.incompleteChecks, }); - return skip( - this.name, - `Not all checks complete yet (${incomplete.length}/${checkStatus.totalCount} still running): ${incomplete.join(', ')}`, - ); + return skip(this.name, decision.message); } - - // Verify at least one check failed - const anyFailed = checkStatus.checkRuns.some( - (cr) => - cr.conclusion === 'failure' || - cr.conclusion === 'timed_out' || - cr.conclusion === 'action_required', - ); - - if (!anyFailed) { + if (decision.action === 'skip') { + if (decision.message.startsWith('All ')) { + logger.info('All checks passed, no action needed', { + prNumber, + totalChecks: checkStatus.totalCount, + }); + } + return skip(this.name, decision.message); + } + if (decision.action === 'review') { logger.info('All checks passed, no action needed', { prNumber, totalChecks: checkStatus.totalCount, diff --git a/src/triggers/github/check-suite-success.ts b/src/triggers/github/check-suite-success.ts index 1d8d7b62b..ca12d78e4 100644 --- a/src/triggers/github/check-suite-success.ts +++ b/src/triggers/github/check-suite-success.ts @@ -2,22 +2,19 @@ import { githubClient } from '../../github/client.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; import { parseRepoFullName } from '../../utils/repo.js'; -import { gateBaseBranch } from '../shared/gates.js'; import { skip } from '../shared/skip.js'; import { checkTriggerEnabledWithParams } from '../shared/trigger-check.js'; +import { decideCheckSuiteGates, decideCheckSuiteOutcome } from './check-suite-decision.js'; +import { resolveCheckSuitePRNumber } from './pr-resolution.js'; import { dispatchRespondToCi } from './respond-to-ci-dispatch.js'; +import { buildReviewResult } from './result-builders.js'; import { buildReviewDispatchKey, claimReviewDispatch, releaseReviewDispatch, } from './review-dispatch-dedup.js'; import { type GitHubCheckSuitePayload, isGitHubCheckSuitePayload } from './types.js'; -import { - evaluateAuthorMode, - parsePrNumberFromRef, - resolveWorkItemDisplayData, - resolveWorkItemId, -} from './utils.js'; +import { parsePrNumberFromRef, resolveWorkItemDisplayData, resolveWorkItemId } from './utils.js'; /** * Dispatches an outcome agent when a check_suite completes with success @@ -70,7 +67,7 @@ export class CheckSuiteSuccessTrigger implements TriggerHandler { if (payload.action !== 'completed') return false; if (payload.check_suite.conclusion !== 'success') return false; - // Must have at least one associated PR, or head_branch must be a refs/pull/{N}/head ref + // Must have at least one associated PR, or head_branch must be a refs/pull/{N}/head ref. const hasPrs = payload.check_suite.pull_requests.length > 0; const hasPrRef = parsePrNumberFromRef(payload.check_suite.head_branch) !== null; if (!hasPrs && !hasPrRef) return false; @@ -93,55 +90,41 @@ export class CheckSuiteSuccessTrigger implements TriggerHandler { const payload = ctx.payload as GitHubCheckSuitePayload; const { owner, repo } = parseRepoFullName(payload.repository.full_name); - // Resolve PR number — from payload directly, or by parsing refs/pull/{N}/head - let prNumber: number; - if (payload.check_suite.pull_requests.length > 0) { - prNumber = payload.check_suite.pull_requests[0].number; - } else { - const parsed = parsePrNumberFromRef(payload.check_suite.head_branch); - if (parsed === null) { - logger.info('Could not parse PR number from head_branch ref, skipping', { - handler: this.name, - }); - return skip(this.name, 'Could not parse PR number from check_suite head_branch'); - } - prNumber = parsed; + const prResolution = await resolveCheckSuitePRNumber({ + owner, + repo, + pullRequests: payload.check_suite.pull_requests, + headBranch: payload.check_suite.head_branch, + handlerName: this.name, + lookupOpenPRByBranch: githubClient.getOpenPRByBranch, + }); + if (!prResolution.ok) { + return skip(this.name, 'Could not parse PR number from check_suite head_branch'); } + const prNumber = prResolution.prNumber; const headSha = payload.check_suite.head_sha; // Fetch PR details const prDetails = await githubClient.getPR(owner, repo, prNumber); - // Gate on PR author based on configured authorMode parameter - const authorResult = evaluateAuthorMode( - prDetails.user.login, - ctx.personaIdentities, - triggerConfig.parameters, - this.name, - ); - if (!authorResult) { - return skip( - this.name, - 'Cascade persona identities could not be resolved (token / GitHub API issue)', - ); - } - if (!authorResult.shouldTrigger) { - logger.info('PR author does not match configured authorMode, skipping', { + const gateDecision = decideCheckSuiteGates({ + prNumber, + prAuthorLogin: prDetails.user.login, + prBaseRef: prDetails.baseRef, + project: ctx.project, + personaIdentities: ctx.personaIdentities, + handlerName: this.name, + mode: { kind: 'review', parameters: triggerConfig.parameters }, + }); + if (gateDecision) { + logger.info('Check-suite success gate skipped dispatch', { handler: this.name, prNumber, - prAuthor: prDetails.user.login, - isCascadePR: authorResult.isCascadePR, - authorMode: authorResult.authorMode, + message: gateDecision.message, }); - return skip( - this.name, - `PR #${prNumber} author ${prDetails.user.login} does not match configured authorMode '${authorResult.authorMode}' (isCascadePR=${authorResult.isCascadePR})`, - ); + return skip(this.name, gateDecision.message); } - const baseSkip = gateBaseBranch(prDetails.baseRef, prNumber, ctx.project, this.name); - if (baseSkip) return baseSkip; - // Resolve work item from DB const workItemId = await resolveWorkItemId(ctx.project.id, prNumber); const { workItemUrl, workItemTitle } = await resolveWorkItemDisplayData(workItemId); @@ -158,31 +141,32 @@ export class CheckSuiteSuccessTrigger implements TriggerHandler { // after PR #1245 (2026-05-01) where the worker polled 2 min, bailed, // and the cross-process dedup blocked all retries. const checkStatus = await githubClient.getCheckSuiteStatus(owner, repo, headSha); - const allComplete = checkStatus.checkRuns.every((cr) => cr.status === 'completed'); - const anyFailed = checkStatus.checkRuns.some( - (cr) => - cr.conclusion === 'failure' || - cr.conclusion === 'timed_out' || - cr.conclusion === 'action_required', - ); + const decision = decideCheckSuiteOutcome({ + checkStatus, + prNumber, + prAuthorLogin: prDetails.user.login, + prBaseRef: prDetails.baseRef, + project: ctx.project, + personaIdentities: ctx.personaIdentities, + handlerName: this.name, + mode: { kind: 'review', parameters: triggerConfig.parameters }, + }); - if (!allComplete) { - const incomplete = checkStatus.checkRuns - .filter((cr) => cr.status !== 'completed') - .map((cr) => cr.name); + if (decision.action === 'defer') { logger.info('Not all checks complete yet, waiting for next check_suite event', { handler: this.name, prNumber, totalChecks: checkStatus.totalCount, - incompleteChecks: incomplete, + incompleteChecks: decision.incompleteChecks, }); - return skip( - this.name, - `Not all checks complete yet (${incomplete.length}/${checkStatus.totalCount} still running): ${incomplete.join(', ')}`, - ); + return skip(this.name, decision.message); } - if (anyFailed) { + if (decision.action === 'skip') { + return skip(this.name, decision.message); + } + + if (decision.action === 'respond-to-ci') { return dispatchRespondToCi({ ctx, prNumber, @@ -196,6 +180,7 @@ export class CheckSuiteSuccessTrigger implements TriggerHandler { }); } + // decision.action === 'review' // allComplete && !anyFailed → review path. Skip if the reviewer // persona's latest review already covers the current HEAD SHA. const reviews = await githubClient.getPRReviews(owner, repo, prNumber); @@ -252,22 +237,11 @@ export class CheckSuiteSuccessTrigger implements TriggerHandler { headSha, }); - const prBranch = prDetails.headRef; - - return { - agentType: 'review', - agentInput: { - prNumber, - prBranch, - repoFullName: payload.repository.full_name, - headSha, - triggerType: 'ci-success', - triggerEvent: 'scm:check-suite-success', - workItemId: workItemId, - }, + return buildReviewResult({ prNumber, - prUrl: prDetails.htmlUrl, - prTitle: prDetails.title, + prDetails, + repoFullName: payload.repository.full_name, + headSha, workItemId, workItemUrl, workItemTitle, @@ -275,6 +249,6 @@ export class CheckSuiteSuccessTrigger implements TriggerHandler { // Fire-and-forget — release is best-effort and the TTL is the safety net. void releaseReviewDispatch(dedupKey); }, - }; + }); } } diff --git a/src/triggers/github/pr-conflict-detected.ts b/src/triggers/github/pr-conflict-detected.ts index 745cade36..20b80c060 100644 --- a/src/triggers/github/pr-conflict-detected.ts +++ b/src/triggers/github/pr-conflict-detected.ts @@ -9,7 +9,9 @@ import { gateTriggerEnabled, requirePersonaIdentities, } from '../shared/gates.js'; +import { buildDeferredRecheckResult } from '../shared/result-builders.js'; import { skip } from '../shared/skip.js'; +import { buildResolveConflictsResult } from './result-builders.js'; import { type GitHubPullRequestPayload, isGitHubPullRequestPayload } from './types.js'; import { resolveWorkItemId } from './utils.js'; @@ -112,14 +114,10 @@ export class PRConflictDetectedTrigger implements TriggerHandler { coalesceKey, delayMs: MERGEABILITY_RECHECK_DELAY_MS, }); - return { - agentType: null, - agentInput: {}, - deferredRecheck: { - delayMs: MERGEABILITY_RECHECK_DELAY_MS, - coalesceKey, - }, - }; + return buildDeferredRecheckResult({ + delayMs: MERGEABILITY_RECHECK_DELAY_MS, + coalesceKey, + }); } // Only fire if PR is unmergeable (has conflicts) @@ -155,21 +153,16 @@ export class PRConflictDetectedTrigger implements TriggerHandler { attempt: attempts + 1, }); - return { - agentType: 'resolve-conflicts', - agentInput: { - prNumber, - prBranch: payload.pull_request.head.ref, - repoFullName, - headSha: payload.pull_request.head.sha, - triggerType: 'conflict-resolution', - triggerEvent: 'scm:pr-conflict-detected', - workItemId: workItemId, - }, + return buildResolveConflictsResult({ prNumber, - prUrl: prDetails.htmlUrl, - prTitle: prDetails.title, + prDetails: { + headRef: payload.pull_request.head.ref, + htmlUrl: prDetails.htmlUrl, + title: prDetails.title, + }, + repoFullName, + headSha: payload.pull_request.head.sha, workItemId, - }; + }); } } diff --git a/src/triggers/github/pr-resolution.ts b/src/triggers/github/pr-resolution.ts new file mode 100644 index 000000000..84a2e4af4 --- /dev/null +++ b/src/triggers/github/pr-resolution.ts @@ -0,0 +1,56 @@ +import type { CreatedPR } from '../../github/client.js'; +import { logger } from '../../utils/logging.js'; +import { parsePrNumberFromRef } from './utils.js'; + +export interface CheckSuitePullRequestRef { + number: number; +} + +export type CheckSuitePRResolution = + | { ok: true; prNumber: number } + | { ok: false; reason: 'unresolved' }; + +export interface ResolveCheckSuitePROptions { + owner: string; + repo: string; + pullRequests: CheckSuitePullRequestRef[]; + headBranch: string | null | undefined; + handlerName: string; + lookupOpenPRByBranch: (owner: string, repo: string, branch: string) => Promise; +} + +/** + * Resolve a PR number from a check_suite payload. + * + * Resolution order intentionally mirrors the historical handler behavior: + * direct `pull_requests[]`, then GitHub's `refs/pull/{N}/head` virtual ref, + * then an open-PR lookup by plain branch name for suites that omit PR links. + */ +export async function resolveCheckSuitePRNumber( + options: ResolveCheckSuitePROptions, +): Promise { + const { owner, repo, pullRequests, headBranch, handlerName, lookupOpenPRByBranch } = options; + + if (pullRequests.length > 0) { + return { ok: true, prNumber: pullRequests[0].number }; + } + + const parsed = parsePrNumberFromRef(headBranch); + if (parsed !== null) { + return { ok: true, prNumber: parsed }; + } + + if (!headBranch) { + logger.info('No pull_requests and no head_branch in payload, skipping', { + handler: handlerName, + }); + return { ok: false, reason: 'unresolved' }; + } + + const pr = await lookupOpenPRByBranch(owner, repo, headBranch); + if (!pr) { + logger.info('No open PR found for head branch, skipping', { handler: handlerName, headBranch }); + return { ok: false, reason: 'unresolved' }; + } + return { ok: true, prNumber: pr.number }; +} diff --git a/src/triggers/github/respond-to-ci-dispatch.ts b/src/triggers/github/respond-to-ci-dispatch.ts index 43fd6283d..2a44ed117 100644 --- a/src/triggers/github/respond-to-ci-dispatch.ts +++ b/src/triggers/github/respond-to-ci-dispatch.ts @@ -3,6 +3,7 @@ import type { TriggerContext, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; import { parseRepoFullName } from '../../utils/repo.js'; import { gateAttemptLimit, gateTriggerEnabled } from '../shared/gates.js'; +import { buildRespondToCiResult } from './result-builders.js'; import type { GitHubCheckSuitePayload } from './types.js'; // Per-PR fix-attempt counter shared across the failure handler and the @@ -83,21 +84,14 @@ export async function dispatchRespondToCi(opts: { }); return { - agentType: 'respond-to-ci', - agentInput: { + ...buildRespondToCiResult({ prNumber: opts.prNumber, - prBranch: opts.prDetails.headRef, + prDetails: opts.prDetails, repoFullName: opts.payload.repository.full_name, headSha: opts.payload.check_suite.head_sha, - triggerType: 'check-failure', - triggerEvent: 'scm:check-suite-failure', workItemId: opts.workItemId, - }, - prNumber: opts.prNumber, - prUrl: opts.prDetails.htmlUrl, - prTitle: opts.prDetails.title, - workItemId: opts.workItemId, - workItemUrl: opts.workItemUrl, - workItemTitle: opts.workItemTitle, + workItemUrl: opts.workItemUrl, + workItemTitle: opts.workItemTitle, + }), }; } diff --git a/src/triggers/github/result-builders.ts b/src/triggers/github/result-builders.ts new file mode 100644 index 000000000..a00e4401b --- /dev/null +++ b/src/triggers/github/result-builders.ts @@ -0,0 +1,72 @@ +import type { TriggerResult } from '../../types/index.js'; +import { TRIGGER_EVENTS } from '../shared/events.js'; +import { buildGitHubPRDispatchResult } from '../shared/result-builders.js'; +import type { PRDetails } from './respond-to-ci-dispatch.js'; + +interface GitHubPRResultBase { + prNumber: number; + prDetails: PRDetails; + repoFullName: string; + headSha: string; + workItemId?: string; + workItemUrl?: string; + workItemTitle?: string; +} + +export function buildRespondToCiResult(options: GitHubPRResultBase): TriggerResult { + return buildGitHubPRDispatchResult({ + agentType: 'respond-to-ci', + triggerEvent: TRIGGER_EVENTS.SCM.CHECK_SUITE_FAILURE, + prNumber: options.prNumber, + prUrl: options.prDetails.htmlUrl, + prTitle: options.prDetails.title, + workItemId: options.workItemId, + workItemUrl: options.workItemUrl, + workItemTitle: options.workItemTitle, + agentInput: { + prBranch: options.prDetails.headRef, + repoFullName: options.repoFullName, + headSha: options.headSha, + triggerType: 'check-failure', + }, + }); +} + +export function buildReviewResult( + options: GitHubPRResultBase & { onBlocked: TriggerResult['onBlocked'] }, +): TriggerResult { + return buildGitHubPRDispatchResult({ + agentType: 'review', + triggerEvent: TRIGGER_EVENTS.SCM.CHECK_SUITE_SUCCESS, + prNumber: options.prNumber, + prUrl: options.prDetails.htmlUrl, + prTitle: options.prDetails.title, + workItemId: options.workItemId, + workItemUrl: options.workItemUrl, + workItemTitle: options.workItemTitle, + onBlocked: options.onBlocked, + agentInput: { + prBranch: options.prDetails.headRef, + repoFullName: options.repoFullName, + headSha: options.headSha, + triggerType: 'ci-success', + }, + }); +} + +export function buildResolveConflictsResult(options: GitHubPRResultBase): TriggerResult { + return buildGitHubPRDispatchResult({ + agentType: 'resolve-conflicts', + triggerEvent: TRIGGER_EVENTS.SCM.PR_CONFLICT_DETECTED, + prNumber: options.prNumber, + prUrl: options.prDetails.htmlUrl, + prTitle: options.prDetails.title, + workItemId: options.workItemId, + agentInput: { + prBranch: options.prDetails.headRef, + repoFullName: options.repoFullName, + headSha: options.headSha, + triggerType: 'conflict-resolution', + }, + }); +} diff --git a/tests/unit/triggers/github/check-suite-decision.test.ts b/tests/unit/triggers/github/check-suite-decision.test.ts new file mode 100644 index 000000000..20c5d8045 --- /dev/null +++ b/tests/unit/triggers/github/check-suite-decision.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { CheckSuiteStatus } from '../../../../src/github/client.js'; +import { + decideCheckSuiteGates, + decideCheckSuiteOutcome, +} from '../../../../src/triggers/github/check-suite-decision.js'; +import { resolveCheckSuitePRNumber } from '../../../../src/triggers/github/pr-resolution.js'; +import { createMockProject } from '../../../helpers/factories.js'; +import { mockPersonaIdentities } from '../../../helpers/mockPersonas.js'; + +const project = createMockProject(); + +function status(checkRuns: CheckSuiteStatus['checkRuns']): CheckSuiteStatus { + return { + totalCount: checkRuns.length, + checkRuns, + allPassing: checkRuns.every( + (checkRun) => checkRun.status === 'completed' && checkRun.conclusion === 'success', + ), + }; +} + +describe('decideCheckSuiteOutcome', () => { + const baseOptions = { + prNumber: 42, + prAuthorLogin: 'cascade-impl', + prBaseRef: 'main', + project, + personaIdentities: mockPersonaIdentities, + handlerName: 'check-suite-success', + } as const; + + it('returns respond-to-ci for mixed success/failure aggregate state', () => { + const decision = decideCheckSuiteOutcome({ + ...baseOptions, + mode: { kind: 'review', parameters: {} }, + checkStatus: status([ + { name: 'lint', status: 'completed', conclusion: 'success' }, + { name: 'test', status: 'completed', conclusion: 'failure' }, + ]), + }); + + expect(decision).toEqual({ action: 'respond-to-ci' }); + }); + + it('returns defer for incomplete checks with the existing skip message text', () => { + const decision = decideCheckSuiteOutcome({ + ...baseOptions, + mode: { kind: 'review', parameters: {} }, + checkStatus: status([ + { name: 'lint', status: 'completed', conclusion: 'success' }, + { name: 'test', status: 'in_progress', conclusion: null }, + ]), + }); + + expect(decision).toEqual({ + action: 'defer', + incompleteChecks: ['test'], + message: 'Not all checks complete yet (1/2 still running): test', + }); + }); + + it('returns review for all-complete passing aggregate state', () => { + const decision = decideCheckSuiteOutcome({ + ...baseOptions, + mode: { kind: 'review', parameters: {} }, + checkStatus: status([ + { name: 'lint', status: 'completed', conclusion: 'success' }, + { name: 'test', status: 'completed', conclusion: 'neutral' }, + ]), + }); + + expect(decision).toEqual({ action: 'review' }); + }); + + it('returns author-mode skip before aggregate decisions', () => { + const decision = decideCheckSuiteGates({ + ...baseOptions, + prAuthorLogin: 'cascade-impl', + mode: { kind: 'review', parameters: { authorMode: 'external' } }, + }); + + expect(decision).toEqual({ + action: 'skip', + message: + "PR #42 author cascade-impl does not match configured authorMode 'external' (isCascadePR=true)", + }); + }); + + it('returns base-branch skip before aggregate decisions', () => { + const decision = decideCheckSuiteGates({ + ...baseOptions, + prBaseRef: 'develop', + mode: { kind: 'review', parameters: {} }, + }); + + expect(decision).toEqual({ + action: 'skip', + message: 'PR #42 targets develop, not project base branch main', + }); + }); +}); + +describe('resolveCheckSuitePRNumber', () => { + const baseOptions = { + owner: 'owner', + repo: 'repo', + handlerName: 'check-suite-failure', + lookupOpenPRByBranch: vi.fn(), + }; + + it('resolves direct PR payloads without branch lookup', async () => { + const lookupOpenPRByBranch = vi.fn(); + + await expect( + resolveCheckSuitePRNumber({ + ...baseOptions, + pullRequests: [{ number: 42 }], + headBranch: 'feature/test', + lookupOpenPRByBranch, + }), + ).resolves.toEqual({ ok: true, prNumber: 42 }); + expect(lookupOpenPRByBranch).not.toHaveBeenCalled(); + }); + + it('resolves refs/pull/N/head without branch lookup', async () => { + const lookupOpenPRByBranch = vi.fn(); + + await expect( + resolveCheckSuitePRNumber({ + ...baseOptions, + pullRequests: [], + headBranch: 'refs/pull/77/head', + lookupOpenPRByBranch, + }), + ).resolves.toEqual({ ok: true, prNumber: 77 }); + expect(lookupOpenPRByBranch).not.toHaveBeenCalled(); + }); + + it('falls back to open PR lookup for plain branch names', async () => { + const lookupOpenPRByBranch = vi.fn().mockResolvedValue({ + number: 99, + htmlUrl: 'https://github.com/owner/repo/pull/99', + title: 'Branch PR', + }); + + await expect( + resolveCheckSuitePRNumber({ + ...baseOptions, + pullRequests: [], + headBranch: 'feature/test', + lookupOpenPRByBranch, + }), + ).resolves.toEqual({ ok: true, prNumber: 99 }); + expect(lookupOpenPRByBranch).toHaveBeenCalledWith('owner', 'repo', 'feature/test'); + }); + + it('returns unresolved when no branch fallback can find a PR', async () => { + const lookupOpenPRByBranch = vi.fn().mockResolvedValue(null); + + await expect( + resolveCheckSuitePRNumber({ + ...baseOptions, + pullRequests: [], + headBranch: 'main', + lookupOpenPRByBranch, + }), + ).resolves.toEqual({ ok: false, reason: 'unresolved' }); + }); +}); diff --git a/tests/unit/triggers/trigger-event-consistency.test.ts b/tests/unit/triggers/trigger-event-consistency.test.ts index 5e87374b1..b0bcfe90e 100644 --- a/tests/unit/triggers/trigger-event-consistency.test.ts +++ b/tests/unit/triggers/trigger-event-consistency.test.ts @@ -117,6 +117,15 @@ function scanHandler(file: string): HandlerScan { if (src.includes('buildPMLabelDispatchResult')) { emittedEvents.add('pm:label-added'); } + if (src.includes('buildReviewResult')) { + emittedEvents.add('scm:check-suite-success'); + } + if (src.includes('buildRespondToCiResult')) { + emittedEvents.add('scm:check-suite-failure'); + } + if (src.includes('buildResolveConflictsResult')) { + emittedEvents.add('scm:pr-conflict-detected'); + } return { file, gatingEvents, emittedEvents }; } From bc23ce09f6047582f357c2e5fcbd0d652187857c Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Sat, 9 May 2026 11:03:01 +0000 Subject: [PATCH 13/18] fix(cascade-tools): explicit topic summaries in --help MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `pjson.oclif.topics` is unset, oclif borrows each topic's description from its first command (`node_modules/@oclif/core/lib/config/config.js` — `this._topics.set(name, { description: c.summary || c.description, name })`). That made bare `cascade-tools --help` show: pm Add a checklist with items to a work item. ... scm Create a GitHub pull request. Handles the full workflow ... alerting Retrieve full details for an alerting event ... session Call this gadget when you have completed all tasks ... Agents reading bare --help to map the surface got a misleading frame (saw ≥ 2× in the 2026-05-09 prod corpus). Set explicit topic summaries so each TOPICS line is one truthful sentence per topic. Also covers the two direct-provider topics (`github`, `trello`) that mirror the canonical `scm` / `pm` surfaces; their summaries point operators back to the provider-agnostic topic. Regression net at tests/unit/cli/cascade-tools-help.test.ts pins both the borrowed-description-must-not-appear and canonical-summary-must-appear invariants for all six topics. Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/cascade-tools.js | 25 +++++++ tests/unit/cli/cascade-tools-help.test.ts | 80 +++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 tests/unit/cli/cascade-tools-help.test.ts diff --git a/bin/cascade-tools.js b/bin/cascade-tools.js index 0d435e633..bec046578 100755 --- a/bin/cascade-tools.js +++ b/bin/cascade-tools.js @@ -24,6 +24,31 @@ pjson.oclif = { globPatterns: ['**/*.js', '!**/dashboard/**', '!**/_shared/**', '!base.js', '!bootstrap.js'], }, topicSeparator: ' ', + // Explicit topic summaries. Without this block oclif borrows each topic's + // description from its FIRST command (see node_modules/@oclif/core + // /lib/config/config.js — the line `this._topics.set(name, { description: + // c.summary || c.description, name })`). That made bare `cascade-tools + // --help` show "pm Add a checklist with items to a work item..." — a + // specific gadget's description leaking into the topic line. Agents reading + // bare --help to map the surface got a misleading frame (saw in 2026-05-09 + // prod corpus). One truthful sentence per topic. + topics: { + pm: { + description: + 'Read and write PM work items, comments, and checklists across Trello/JIRA/Linear.', + }, + scm: { + description: 'Interact with GitHub PRs: create, review, comment, fetch diffs and CI logs.', + }, + alerting: { description: 'Inspect Sentry alerting issues and events.' }, + session: { description: 'End the agent session. Exclusive terminal call.' }, + github: { + description: 'Direct GitHub provider commands. Prefer the provider-agnostic `scm` topic.', + }, + trello: { + description: 'Direct Trello provider commands. Prefer the provider-agnostic `pm` topic.', + }, + }, }; const config = await Config.load({ root, pjson }); diff --git a/tests/unit/cli/cascade-tools-help.test.ts b/tests/unit/cli/cascade-tools-help.test.ts new file mode 100644 index 000000000..4e5610829 --- /dev/null +++ b/tests/unit/cli/cascade-tools-help.test.ts @@ -0,0 +1,80 @@ +/** + * Pin the topic-list rendering of `cascade-tools --help`. + * + * Prod regression 2026-05-09: when `pjson.oclif.topics` is unset, oclif + * borrows each topic's description from its FIRST command (see + * `node_modules/@oclif/core/lib/config/config.js:297-300`). That made bare + * `cascade-tools --help` show: + * - `pm` topic = description of `pm add-checklist` ("Add a checklist...") + * - `scm` topic = description of `scm create-pr` + * - `alerting` topic = description of `alerting get-alerting-event` + * - `session` topic = description of `session finish` + * + * Agents reading bare `--help` to map the surface get a misleading frame and + * make wrong assumptions. The fix is one explicit topic-summary block in the + * cascade-tools entrypoint; this test pins it so future drift fails CI. + */ + +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const REPO_ROOT = resolve(__dirname, '../../..'); +const BIN = resolve(REPO_ROOT, 'bin/cascade-tools.js'); +const DIST = resolve(REPO_ROOT, 'dist/cli/bootstrap.js'); + +function runHelp(args: string[]): { stdout: string; stderr: string; code: number | null } { + // Strip NODE_ENV — vitest sets it to 'test' which trips the integration + // entrypoint loaded by `bin/cascade-tools.js` and exits 2 with no diagnostic. + // Unrelated to the help-rendering surface this test covers. + const env = { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' }; + delete env.NODE_ENV; + const result = spawnSync('node', [BIN, ...args], { + cwd: REPO_ROOT, + encoding: 'utf-8', + env, + timeout: 30_000, + }); + return { stdout: result.stdout ?? '', stderr: result.stderr ?? '', code: result.status }; +} + +describe('cascade-tools --help — topic summaries', () => { + // The CLI requires the dist build to exist (bin/cascade-tools.js imports + // from ../dist/cli/bootstrap.js). Skip with a clear message if not built. + const built = existsSync(DIST); + + it.skipIf(!built)('topic descriptions are explicit, not borrowed from gadgets', () => { + const { stdout, code } = runHelp(['--help']); + expect(code).toBe(0); + + // Must NOT borrow gadget descriptions. These are the first-gadget + // descriptions that prod showed leaking into topic lines. + expect(stdout).not.toMatch( + /pm\s+(?:Add a checklist|Read a work item|Post a comment|Update a work item|Create a new work item|List all work items|Move a work item)/, + ); + expect(stdout).not.toMatch(/scm\s+Create a GitHub pull request\./); + expect(stdout).not.toMatch(/alerting\s+Retrieve full details for an alerting event/); + expect(stdout).not.toMatch(/session\s+Call this gadget when you have completed all tasks/); + expect(stdout).not.toMatch(/github\s+Create a GitHub pull request with optional commit/); + expect(stdout).not.toMatch(/trello\s+Add a checklist with items to a Trello card/); + + // Must contain canonical topic summaries for every discovered topic. + expect(stdout).toContain('TOPICS'); + expect(stdout).toMatch(/pm\s+Read and write PM work items/i); + expect(stdout).toMatch(/scm\s+Interact with GitHub PRs/i); + expect(stdout).toMatch(/alerting\s+Inspect Sentry alerting/i); + expect(stdout).toMatch(/session\s+End the agent session/i); + expect(stdout).toMatch(/github\s+Direct GitHub provider commands/i); + expect(stdout).toMatch(/trello\s+Direct Trello provider commands/i); + }); + + it.skipIf(!built)('per-gadget --help is unaffected (topic-summary fix is additive)', () => { + const { stdout, code } = runHelp(['pm', 'read-work-item', '--help']); + expect(code).toBe(0); + // Spot-check: the gadget's own description / flags are still rendered. + expect(stdout).toContain('Read a work item'); + expect(stdout).toContain('--workItemId'); + expect(stdout).toContain('--[no-]includeComments'); + }); +}); From a8c17c89fbd81eaf317e3958cb4bce545093d3db Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Sat, 9 May 2026 11:05:56 +0000 Subject: [PATCH 14/18] chore(cascade-tools): pin oclif strict mode on CredentialScopedCommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Locks oclif's documented default (`strict = true`) explicitly. Without it, unknown flags would slip past parse validation and reach the gadget body as positional args — silently bypassing the spec-014 `unknown-flag` envelope even though every cascade-tools command claims to honor it. Adds a regression test on the factory-generated command surface that asserts unknown flags fire the structured `unknown-flag` envelope (passes today; guards against any future drift if oclif loosens the default). Note: the original 2026-05-09 analysis read run 27be3592 as a strict-mode gap (`session finish --agent-type review --review-submitted --comment ...` returning success despite `finishDef` declaring only `comment`). Closer inspection of `src/cli/session/finish.ts` shows it's a hand-written oclif command — `--agent-type`, `--pr-created`, and `--review-submitted` are legitimate CLI extensions, listed in `--help`. Not a real bug. The pin in this commit is preventive, not a fix. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cli/base.ts | 9 ++++++++ tests/unit/cli/cli-command-factory.test.ts | 26 ++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/cli/base.ts b/src/cli/base.ts index 5765024f9..3b984169d 100644 --- a/src/cli/base.ts +++ b/src/cli/base.ts @@ -114,6 +114,15 @@ function synthesizeProjectFromEnv(pmType: PMType): ProjectConfig { } export abstract class CredentialScopedCommand extends Command { + /** + * Pin oclif strict mode (its documented default) for every cascade-tools + * command. Without strict, unknown flags slip past parse validation and + * reach the gadget body as positional args — silently bypassing the + * spec-014 `unknown-flag` envelope. Locking the default explicitly guards + * against future oclif behavior drift and makes the assumption visible. + */ + static override strict = true; + /** Subclasses implement this instead of run() */ abstract execute(): Promise; diff --git a/tests/unit/cli/cli-command-factory.test.ts b/tests/unit/cli/cli-command-factory.test.ts index 98a117f8d..a20aaa5f5 100644 --- a/tests/unit/cli/cli-command-factory.test.ts +++ b/tests/unit/cli/cli-command-factory.test.ts @@ -711,4 +711,30 @@ describe('cliCommandFactory — parse-error envelope wrapping (spec 014, #4)', ( expect(output.success).toBe(false); expect(output.error.type).toBe('unknown-flag'); }); + + // Pin oclif strict mode on the factory-generated commands. CredentialScopedCommand + // now sets `static override strict = true` explicitly; without that, future oclif + // behavior drift could let unknown flags slip past parse validation and reach the + // gadget body as positional args — bypassing the spec-014 unknown-flag envelope. + it('rejects unknown flags on a factory-generated command (strict mode pinned)', async () => { + const coreFn = vi.fn().mockResolvedValue('ok'); + const def = makeToolDef({ + name: 'Finish', + parameters: { + comment: { type: 'string', describe: 'A brief summary', required: true }, + }, + }); + const Cmd = createCLICommand(def, coreFn); + const cmd = new Cmd(['--unknown-flag', 'foo', '--comment', 'x'], makeMockConfig() as never); + const logSpy = vi.spyOn(cmd, 'log'); + await expect(cmd.run()).rejects.toThrow(); + expect(coreFn).not.toHaveBeenCalled(); + const output = JSON.parse(logSpy.mock.calls[0][0] as string) as { + success: boolean; + error: { type: string; flag?: string }; + }; + expect(output.success).toBe(false); + expect(output.error.type).toBe('unknown-flag'); + expect(output.error.flag).toBe('--unknown-flag'); + }); }); From ffc0e8e71a499b7f441404700c06fb9377eead66 Mon Sep 17 00:00:00 2001 From: aaight Date: Sat, 9 May 2026 13:16:59 +0200 Subject: [PATCH 15/18] refactor(triggers): thin agent execution facade (#1284) Co-authored-by: Cascade Bot --- docs/architecture/03-trigger-system.md | 37 ++-- src/triggers/README.md | 46 +++- .../shared/agent-execution-followups.ts | 77 +++++++ .../shared/agent-execution-runtime.ts | 108 ++++++++++ src/triggers/shared/agent-execution.ts | 196 ++++-------------- 5 files changed, 290 insertions(+), 174 deletions(-) create mode 100644 src/triggers/shared/agent-execution-followups.ts create mode 100644 src/triggers/shared/agent-execution-runtime.ts diff --git a/docs/architecture/03-trigger-system.md b/docs/architecture/03-trigger-system.md index c3b7adb25..98cbea9f9 100644 --- a/docs/architecture/03-trigger-system.md +++ b/docs/architecture/03-trigger-system.md @@ -173,27 +173,30 @@ Each trigger in a YAML agent definition can declare a `contextPipeline` — an o `src/triggers/shared/agent-execution.ts` -After a trigger matches, the shared execution layer handles the agent lifecycle: +After a trigger matches, the shared execution layer handles the agent lifecycle. `runAgentExecutionPipeline()` is intentionally a thin facade: it keeps the source-compatible call signature used by PM, GitHub, Sentry, and manual paths, while delegating each execution concern to helper modules under `src/triggers/shared/`. ```mermaid flowchart TD - A[Trigger matched] --> B[PM lifecycle: prepareForAgent] - B --> C[Check budget] - C -->|Over budget| D[Post budget warning, skip] - C -->|Within budget| E[Resolve agent definition] - E --> F[Set credential scope] + A[Trigger matched] --> B[Guard and context setup] + B --> C[Validation and budget preflight] + C -->|Blocked| D[Notify PM/callbacks and stop] + C -->|Allowed| E[Persist work-item and PR links] + E --> F[PM lifecycle: prepareForAgent] F --> G[Run agent via engine] - G -->|Success| H[PM lifecycle: handleSuccess] - G -->|Failure| I[PM lifecycle: handleFailure] - H --> J[Trigger debug analysis if configured] - I --> J + G --> H[Post-run side effects] + H --> I[PM lifecycle cleanup and success/failure] + I --> J[Source callbacks] + J --> K[Follow-up dispatch] + K --> L[Auto-debug if eligible] ``` This includes: -- PM lifecycle management (move card to "In Progress", post labels) -- Budget checking (`workItemBudgetUsd`) -- Credential scoping via `withCredentials()` -- Agent execution via `runAgent()` (see [05-engine-backends](./05-engine-backends.md)) -- Post-run lifecycle (move card to "In Review", link PR, sync checklists) -- Debug analysis triggering on failure -- Deterministic review dispatch after a successful implementation run with a PR, using the same dedup key as the `scm:check-suite-success` trigger +- Context setup in `agent-execution-runtime.ts`: build the `PMLifecycleManager`, load agent lifecycle hooks, and re-resolve `workItemId` from PR links when a webhook arrived before the DB mapping existed. +- Validation and lifecycle preflight in `agent-execution-lifecycle.ts`: validate PM/SCM integrations, notify PM/callbacks on validation failure, check `workItemBudgetUsd`, and run `prepareForAgent`. +- Work-item and PR traceability in `agent-work-items.ts`: create/update work-item records, maintain PR/work-item links before and after execution, fetch PR titles, and backfill run PR numbers. +- Agent execution in `agent-execution-runtime.ts`: call `runAgent()` with the resolved input plus project, config, and remaining budget. +- Post-run PM behavior in `agent-pm-summary.ts` and `agent-execution-lifecycle.ts`: post review/output summaries to the PM work item, handle artifacts, post budget warnings, clean up processing state, and call `handleSuccess` or `handleFailure`. +- Follow-up dispatch in `agent-execution-followups.ts`: dispatch review after a successful implementation PR once CI is passing and the review dedup key is claimed, and chain backlog-manager after a successful splitting run when the auto label/capacity checks allow it. +- Auto-debug in `agent-auto-debug.ts`: fire-and-forget debug analysis for eligible failed or timed-out runs after callbacks and follow-up dispatch complete. + +Credential scoping still happens before the facade runs. PM webhook handling enters provider credentials and PM provider scope before dispatch; GitHub and Sentry use `webhook-execution.ts` / `credential-scope.ts` to inject LLM keys, PM credentials, PM provider scope, and GitHub persona tokens as needed. diff --git a/src/triggers/README.md b/src/triggers/README.md index ed377826f..f70b7270c 100644 --- a/src/triggers/README.md +++ b/src/triggers/README.md @@ -79,7 +79,15 @@ To reduce duplication across the three worker-side handlers, shared utilities ar | `pm-ack.ts` | `postPMAckComment()` — posts ack to Trello/JIRA | GitHub worker handler | | `events.ts` | `TRIGGER_EVENTS` — typed catalog of canonical trigger event names | Trigger handlers and tests | | `result-builders.ts` | Builders for dispatch, skip, no-agent, and deferred re-check `TriggerResult` shapes | Trigger handlers and tests | -| `agent-execution.ts` | `runAgentExecutionPipeline()` — full agent lifecycle | All handlers (via `webhook-execution.ts`) | +| `agent-execution.ts` | `runAgentExecutionPipeline()` — thin facade that orders validation, linking, prepare/run, post-run work, callbacks, follow-up dispatch, and auto-debug | All handlers (via `webhook-execution.ts`) | +| `agent-execution-runtime.ts` | Context setup, lifecycle hook loading, `runAgent()` invocation, PR/work-item linking, PM summaries, and source callbacks | `agent-execution.ts` | +| `agent-execution-lifecycle.ts` | Integration validation, pre/post budget checks, PM prepare/cleanup/success/failure lifecycle, and artifact handling | `agent-execution.ts` | +| `agent-work-items.ts` | Runtime work-item re-resolution, pre-run work-item persistence, PR/work-item linking, and run PR-number backfill | `agent-execution-runtime.ts` | +| `agent-pm-summary.ts` | Cross-source PM summaries for review and output-based agents | `agent-execution-runtime.ts` | +| `agent-execution-followups.ts` | Recursive follow-up dispatch for post-completion review and splitting auto-chain | `agent-execution.ts` | +| `post-completion-review.ts` | Builds the deterministic review dispatch after a successful implementation PR with passing CI | `agent-execution-followups.ts` | +| `splitting-auto-chain.ts` | Propagates the auto label after splitting and optionally chains backlog-manager | `agent-execution-followups.ts` | +| `agent-auto-debug.ts` | Triggers configured debug analysis after failed or timed-out runs | `agent-execution.ts` | | `webhook-execution.ts` | `runAgentWithCredentials()` — LLM keys + credentials + pipeline | GitHub, PM | --- @@ -128,6 +136,38 @@ processSentryWebhook(payload, projectId, registry, triggerResult) └─ resolveTriggerResult(registry, ctx, preResolved) └─ withAgentTypeConcurrency(projectId, agentType) └─ startWatchdog() - └─ withPMScope(project) - └─ runAgentExecutionPipeline(result, ...) + └─ withPMScope(project) + └─ runAgentExecutionPipeline(result, ...) +``` + +### Agent execution facade + +``` +runAgentExecutionPipeline(result, project, config, executionConfig) + └─ guard: skip no-agent TriggerResult values + └─ createAgentExecutionContext() + └─ create PM lifecycle manager + └─ load agent lifecycle hooks + └─ re-resolve workItemId from stored PR/work-item links when needed + └─ validateAgentExecutionLifecycle() + └─ validate PM/SCM credentials and notify PM/callbacks on preflight failure + └─ checkPreRunBudget() + └─ stop before run when workItemBudgetUsd is exceeded + └─ persistAgentWorkItemLinks() + └─ create/update work-item records and PR/work-item links before the run + └─ prepareAgentExecutionLifecycle() + └─ PM prepareForAgent unless the source config skips it + └─ runAgentForContext() + └─ runAgent(agentType, agentInput + project/config/remainingBudgetUsd) + └─ runPostAgentSideEffects() + └─ link created PRs back to work items and post PM summaries + └─ runPostAgentExecutionLifecycle() + └─ artifacts, post-run budget warning, cleanupProcessing, handleSuccess/Failure + └─ runAgentExecutionCallbacks() + └─ source-specific success/failure callbacks + └─ dispatchAgentFollowUps() + └─ implementation success + green CI → review dispatch + └─ splitting success + auto label → backlog-manager auto-chain + └─ triggerAutoDebugIfNeeded() + └─ fire-and-forget debug analysis for eligible failed/timed-out runs ``` diff --git a/src/triggers/shared/agent-execution-followups.ts b/src/triggers/shared/agent-execution-followups.ts new file mode 100644 index 000000000..d1c94f2d2 --- /dev/null +++ b/src/triggers/shared/agent-execution-followups.ts @@ -0,0 +1,77 @@ +import type { AgentResult } from '../../types/index.js'; +import { logger } from '../../utils/logging.js'; +import type { TriggerResult } from '../types.js'; +import type { AgentExecutionConfig, AgentExecutionContext } from './agent-execution-types.js'; +import { buildPostCompletionReviewDispatch } from './post-completion-review.js'; +import { buildSplittingAutoChainDispatch } from './splitting-auto-chain.js'; + +export type AgentExecutionRunner = ( + result: TriggerResult, + project: AgentExecutionContext['project'], + config: AgentExecutionContext['config'], + executionConfig?: AgentExecutionConfig, +) => Promise; + +async function dispatchPostCompletionReview( + context: AgentExecutionContext, + agentResult: AgentResult, + runner: AgentExecutionRunner, +): Promise { + if (context.agentType !== 'implementation' || !agentResult.success || !agentResult.prUrl) return; + if (!context.project.repo) return; + + const reviewResult = await buildPostCompletionReviewDispatch( + agentResult, + context.project, + context.workItemId, + ); + if (!reviewResult) return; + + try { + await runner(reviewResult, context.project, context.config, { + ...context.executionConfig, + skipPrepareForAgent: true, + skipHandleFailure: true, + logLabel: 'review (post-completion)', + }); + } catch (err) { + logger.warn('Post-completion review pipeline failed (non-fatal)', { + prUrl: agentResult.prUrl, + workItemId: context.workItemId, + error: String(err), + }); + } +} + +async function dispatchSplittingAutoChain( + context: AgentExecutionContext, + agentResult: AgentResult, + runner: AgentExecutionRunner, +): Promise { + if (context.agentType !== 'splitting' || !agentResult.success || !context.workItemId) return; + + const chainResult = await buildSplittingAutoChainDispatch(context.workItemId, context.project); + if (!chainResult) return; + + await runner(chainResult, context.project, context.config, { + ...context.executionConfig, + skipPrepareForAgent: true, + skipHandleFailure: true, + logLabel: 'backlog-manager (auto-chain)', + }); +} + +/** + * Dispatch recursive follow-up work owned by the execution facade. + * + * The facade passes itself as `runner` so recursion remains centralized while + * this module owns the trigger-specific follow-up decisions. + */ +export async function dispatchAgentFollowUps( + context: AgentExecutionContext, + agentResult: AgentResult, + runner: AgentExecutionRunner, +): Promise { + await dispatchPostCompletionReview(context, agentResult, runner); + await dispatchSplittingAutoChain(context, agentResult, runner); +} diff --git a/src/triggers/shared/agent-execution-runtime.ts b/src/triggers/shared/agent-execution-runtime.ts new file mode 100644 index 000000000..3f83a272e --- /dev/null +++ b/src/triggers/shared/agent-execution-runtime.ts @@ -0,0 +1,108 @@ +import { getAgentProfile } from '../../agents/definitions/profiles.js'; +import type { LifecycleHooks } from '../../agents/definitions/schema.js'; +import { runAgent } from '../../agents/registry.js'; +import { createPMProvider, PMLifecycleManager, resolveProjectPMConfig } from '../../pm/index.js'; +import type { AgentResult, CascadeConfig, ProjectConfig } from '../../types/index.js'; +import { logger } from '../../utils/logging.js'; +import type { TriggerResult } from '../types.js'; +import type { AgentExecutionConfig, AgentExecutionContext } from './agent-execution-types.js'; +import { postAgentSummaryToPM } from './agent-pm-summary.js'; +import { + linkPRPostExecution, + persistPreRunWorkItems, + prepareAgentWorkItem, +} from './agent-work-items.js'; + +async function loadLifecycleHooks(agentType: string): Promise { + try { + const agentProfile = await getAgentProfile(agentType); + return agentProfile.lifecycleHooks; + } catch (err) { + logger.warn('Failed to load agent profile for lifecycle hooks, using defaults', { + agentType, + error: String(err), + }); + return {}; + } +} + +export async function createAgentExecutionContext( + result: TriggerResult, + project: ProjectConfig, + config: CascadeConfig, + executionConfig: AgentExecutionConfig, +): Promise { + if (!result.agentType) { + throw new Error('createAgentExecutionContext requires result.agentType'); + } + + const pmProvider = createPMProvider(project); + const pmConfig = resolveProjectPMConfig(project); + const lifecycle = new PMLifecycleManager(pmProvider, pmConfig); + const lifecycleHooks = await loadLifecycleHooks(result.agentType); + const { workItemId, agentInput } = await prepareAgentWorkItem(result, project.id); + + return { + result, + project, + config, + executionConfig, + agentType: result.agentType, + logLabel: executionConfig.logLabel ?? 'Agent', + lifecycle, + lifecycleHooks, + workItemId, + agentInput, + }; +} + +export async function persistAgentWorkItemLinks(context: AgentExecutionContext): Promise { + await persistPreRunWorkItems(context.result, context.project, context.workItemId); +} + +export async function runAgentForContext( + context: AgentExecutionContext, + remainingBudgetUsd: number | undefined, +): Promise { + return runAgent(context.agentType, { + ...context.agentInput, + remainingBudgetUsd, + project: context.project, + config: context.config, + }); +} + +export async function runPostAgentSideEffects( + context: AgentExecutionContext, + agentResult: AgentResult, +): Promise { + if (agentResult.success && agentResult.prUrl && context.project.repo) { + await linkPRPostExecution( + agentResult as AgentResult & { prUrl: string }, + context.project as ProjectConfig & { repo: string }, + context.result, + context.workItemId, + ); + } + + await postAgentSummaryToPM( + context.agentType, + agentResult, + context.workItemId, + context.project.id, + context.result.prNumber, + ); +} + +export async function runAgentExecutionCallbacks( + context: AgentExecutionContext, + agentResult: AgentResult, +): Promise { + if (context.executionConfig.onSuccess && agentResult.success) { + await context.executionConfig.onSuccess(context.result, agentResult); + } + + if (context.executionConfig.onFailure && !agentResult.success) { + await context.executionConfig.onFailure(context.result, agentResult); + } +} diff --git a/src/triggers/shared/agent-execution.ts b/src/triggers/shared/agent-execution.ts index 1a3aab471..f0e8bc64d 100644 --- a/src/triggers/shared/agent-execution.ts +++ b/src/triggers/shared/agent-execution.ts @@ -1,26 +1,22 @@ -import { getAgentProfile } from '../../agents/definitions/profiles.js'; -import type { LifecycleHooks } from '../../agents/definitions/schema.js'; -import { runAgent } from '../../agents/registry.js'; -import { createPMProvider, PMLifecycleManager, resolveProjectPMConfig } from '../../pm/index.js'; -import type { AgentResult, CascadeConfig, ProjectConfig } from '../../types/index.js'; +import type { CascadeConfig, ProjectConfig } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; import type { TriggerResult } from '../types.js'; import { triggerAutoDebugIfNeeded } from './agent-auto-debug.js'; +import { dispatchAgentFollowUps } from './agent-execution-followups.js'; import { checkPreRunBudget, prepareAgentExecutionLifecycle, runPostAgentExecutionLifecycle, validateAgentExecutionLifecycle, } from './agent-execution-lifecycle.js'; -import type { AgentExecutionConfig, AgentExecutionContext } from './agent-execution-types.js'; -import { postAgentSummaryToPM } from './agent-pm-summary.js'; import { - linkPRPostExecution, - persistPreRunWorkItems, - prepareAgentWorkItem, -} from './agent-work-items.js'; -import { buildPostCompletionReviewDispatch } from './post-completion-review.js'; -import { buildSplittingAutoChainDispatch } from './splitting-auto-chain.js'; + createAgentExecutionContext, + persistAgentWorkItemLinks, + runAgentExecutionCallbacks, + runAgentForContext, + runPostAgentSideEffects, +} from './agent-execution-runtime.js'; +import type { AgentExecutionConfig } from './agent-execution-types.js'; export type { AgentExecutionConfig } from './agent-execution-types.js'; @@ -28,14 +24,14 @@ export type { AgentExecutionConfig } from './agent-execution-types.js'; * Shared agent execution pipeline. * * Handles the common steps across all webhook handlers: - * 1. Budget check (pre-run) - * 2. Lifecycle preparation (prepareForAgent) - * 3. Run the agent - * 4. Handle artifacts - * 5. Post-run budget check - * 6. Lifecycle cleanup - * 7. Handle success/failure - * 8. Auto-debug + * 1. Guard and context setup + * 2. Validation and preflight budget checks + * 3. Work-item persistence/linking + * 4. Lifecycle preparation (prepareForAgent) + * 5. Run the agent + * 6. Post-run side effects and lifecycle cleanup + * 7. Source callbacks + * 8. Follow-up dispatch and auto-debug * * Source-specific behavior (e.g. GitHub skipping prepareForAgent or * only calling handleSuccess for 'implementation') is controlled via @@ -44,7 +40,6 @@ export type { AgentExecutionConfig } from './agent-execution-types.js'; * This function must be called inside credential/PM-provider context * (e.g. `withTrelloCredentials`, `withPMProvider`, `withGitHubToken`). */ -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: intentional — pipeline with multiple conditional branches + splitting auto-chain export async function runAgentExecutionPipeline( result: TriggerResult, project: ProjectConfig, @@ -55,72 +50,25 @@ export async function runAgentExecutionPipeline( logger.warn('No agent type in trigger result, skipping execution pipeline'); return; } - const agentType = result.agentType; - // Create lifecycle manager once (reused for validation failure and normal flow) - const pmProvider = createPMProvider(project); - const pmConfig = resolveProjectPMConfig(project); - const lifecycle = new PMLifecycleManager(pmProvider, pmConfig); - - // Load lifecycle hooks from agent profile (best-effort — defaults to no-op on failure) - let lifecycleHooks: LifecycleHooks = {}; - try { - const agentProfile = await getAgentProfile(agentType); - lifecycleHooks = agentProfile.lifecycleHooks; - } catch (err) { - logger.warn('Failed to load agent profile for lifecycle hooks, using defaults', { - agentType, - error: String(err), - }); - } - - const canExecute = await validateAgentExecutionLifecycle({ + const executionContext = await createAgentExecutionContext( result, project, - agentType, - lifecycle, + config, executionConfig, + ); + + const canExecute = await validateAgentExecutionLifecycle({ + result: executionContext.result, + project: executionContext.project, + agentType: executionContext.agentType, + lifecycle: executionContext.lifecycle, + executionConfig: executionContext.executionConfig, }); if (!canExecute) { return; } - const { onSuccess, onFailure, logLabel = 'Agent' } = executionConfig; - - // Re-resolve workItemId at run time. The trigger handler (e.g. PROpenedTrigger) - // captures workItemId synchronously at webhook arrival, before any other - // pipeline has had time to link the PR. By the time we run, the DB may have - // caught up — preferring the live value avoids carrying a stale `undefined` - // into runAgent (and therefore agent_runs.work_item_id) and into the - // post-execution linkPRToWorkItem write. - const { workItemId, agentInput } = await prepareAgentWorkItem(result, project.id); - - // Patch agentInput.workItemId whenever it diverges from the resolved value. - // Two cases this catches: - // 1. Re-resolution recovered a workItemId the trigger didn't have at - // webhook-arrival time (the original motivation — see PROpenedTrigger). - // 2. The trigger set workItemId at the top level of its TriggerResult but - // forgot to include it inside `agentInput` (live incident: respond-to- - // review and respond-to-pr-comment, 2026-04-29 — 0/103 and 0/9 runs - // had a non-null work_item_id, hiding them from the dashboard's - // work-item page). The static guard at - // tests/unit/triggers/trigger-work-item-id-consistency.test.ts - // catches this at write-time; this runtime patch is the safety net. - // tryCreateRun (src/agents/shared/runTracking.ts) reads workItemId from - // agentInput when persisting agent_runs.work_item_id. - const executionContext: AgentExecutionContext = { - result, - project, - config, - executionConfig, - agentType, - logLabel, - lifecycle, - lifecycleHooks, - workItemId, - agentInput, - }; - let remainingBudgetUsd: number | undefined; if (executionContext.workItemId) { const budgetResult = await checkPreRunBudget( @@ -132,92 +80,32 @@ export async function runAgentExecutionPipeline( remainingBudgetUsd = budgetResult.remainingBudgetUsd; } - await persistPreRunWorkItems(result, project, workItemId); + await persistAgentWorkItemLinks(executionContext); await prepareAgentExecutionLifecycle(executionContext); - const agentResult = await runAgent(executionContext.agentType, { - ...executionContext.agentInput, - remainingBudgetUsd, - project: executionContext.project, - config: executionContext.config, - }); - - // Link PR to work item post-execution (single code path for all backends) - if (agentResult.success && agentResult.prUrl && project.repo) { - await linkPRPostExecution( - agentResult as AgentResult & { prUrl: string }, - project as ProjectConfig & { repo: string }, - result, - workItemId, - ); - } - - // Post agent summary to PM work item (cross-source: works for all trigger types) - await postAgentSummaryToPM(agentType, agentResult, workItemId, project.id, result.prNumber); + const agentResult = await runAgentForContext(executionContext, remainingBudgetUsd); + await runPostAgentSideEffects(executionContext, agentResult); - if (workItemId) { + if (executionContext.workItemId) { await runPostAgentExecutionLifecycle( - workItemId, - agentType, + executionContext.workItemId, + executionContext.agentType, agentResult, - project, - lifecycle, - lifecycleHooks, - executionConfig, + executionContext.project, + executionContext.lifecycle, + executionContext.lifecycleHooks, + executionContext.executionConfig, ); } - logger.info(`${logLabel} completed`, { - agentType, + logger.info(`${executionContext.logLabel} completed`, { + agentType: executionContext.agentType, success: agentResult.success, runId: agentResult.runId, }); - if (onSuccess && agentResult.success) { - await onSuccess(result, agentResult); - } - - if (onFailure && !agentResult.success) { - await onFailure(result, agentResult); - } - - // Post-completion review dispatch: when an implementation agent succeeds - // with a PR, check CI and fire review deterministically. This guarantees - // review dispatch within seconds of completion, regardless of webhook - // timing (spec 007). Uses the same recursive pattern as the splitting → - // backlog-manager chain below. - if (agentType === 'implementation' && agentResult.success && agentResult.prUrl && project.repo) { - const reviewResult = await buildPostCompletionReviewDispatch(agentResult, project, workItemId); - if (reviewResult) { - try { - await runAgentExecutionPipeline(reviewResult, project, config, { - ...executionConfig, - skipPrepareForAgent: true, - skipHandleFailure: true, - logLabel: 'review (post-completion)', - }); - } catch (err) { - logger.warn('Post-completion review pipeline failed (non-fatal)', { - prUrl: agentResult.prUrl, - workItemId, - error: String(err), - }); - } - } - } - - // After a successful splitting run, propagate auto label and optionally chain backlog-manager - if (agentType === 'splitting' && agentResult.success && workItemId) { - const chainResult = await buildSplittingAutoChainDispatch(workItemId, project); - if (chainResult) { - await runAgentExecutionPipeline(chainResult, project, config, { - ...executionConfig, - skipPrepareForAgent: true, - skipHandleFailure: true, - logLabel: 'backlog-manager (auto-chain)', - }); - } - } + await runAgentExecutionCallbacks(executionContext, agentResult); + await dispatchAgentFollowUps(executionContext, agentResult, runAgentExecutionPipeline); - await triggerAutoDebugIfNeeded(agentResult, project, config); + await triggerAutoDebugIfNeeded(agentResult, executionContext.project, executionContext.config); } From 072f81b3cb6f6d0ed4926219f6bf539d218bd453 Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Sat, 9 May 2026 11:17:11 +0000 Subject: [PATCH 16/18] fix(cascade-tools): drop inherited LLMIST_LOG_TEE so stdout stays envelope-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prod 2026-05-09: 75/120 cascade-tools calls (62%) emitted DEBUG/INFO log lines + ANSI escapes on stdout BEFORE the JSON envelope, polluting the agent's tool-result channel. Root cause: the worker process at `src/backends/llmist/index.ts:83-84` sets `LLMIST_LOG_FILE=` AND `LLMIST_LOG_TEE='true'` so its own logger tees to both file and stdout. Both env vars are in the subprocess allowlist (`src/utils/cascadeEnv.ts:14-15`) and pass through to the bash subprocess that runs cascade-tools. The cascade-tools logger then ALSO tees, polluting the agent's view. Fix: strip `LLMIST_LOG_TEE` at the very top of `bin/cascade-tools.js` (before the singleton logger reads env). With `LLMIST_LOG_FILE` still set, all logs (including the load-bearing `[image-pipeline] work-item-fetch summary` per spec 016 / `src/integrations/README.md`) land in the engine log file the worker collects — operator observability via `cascade runs logs ` is preserved. For standalone runs (no LLMIST_LOG_FILE inherited), redirect to /dev/null so dev runs stay envelope-only too; developers can override with `LLMIST_LOG_FILE=/tmp/x.log cascade-tools ...`. No source-tree code changes — `src/utils/logging.ts`, the cascade logger usage everywhere, and the `[image-pipeline]` log line all unchanged. The fix is one entrypoint env tweak. Regression net at tests/unit/cli/cascade-tools-stdout-cleanliness.test.ts spawns cascade-tools as a subprocess under three env shapes (worker / standalone / dev-override) and asserts stdout matches `^{"success":` with no ANSI escapes (ESC byte 0x1b absent), no `[cascade]` substring, and no tab-separated log-level prefixes. Also asserts the engine log file DOES receive the cascade logger output — the operator-observability invariant. Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/cascade-tools.js | 23 ++++ .../cascade-tools-stdout-cleanliness.test.ts | 124 ++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 tests/unit/cli/cascade-tools-stdout-cleanliness.test.ts diff --git a/bin/cascade-tools.js b/bin/cascade-tools.js index bec046578..f3e5d27ed 100755 --- a/bin/cascade-tools.js +++ b/bin/cascade-tools.js @@ -1,4 +1,27 @@ #!/usr/bin/env node + +// cascade-tools' stdout is reserved for the JSON envelope agents parse. The +// worker process at `src/backends/llmist/index.ts` sets +// `LLMIST_LOG_FILE=` AND `LLMIST_LOG_TEE='true'` so its OWN +// logger tees to both the engine log file AND stdout. Both env vars are in +// the subprocess allowlist (`src/utils/cascadeEnv.ts`) and pass through to +// the bash subprocess that runs cascade-tools — making the cascade-tools +// logger ALSO tee to stdout, polluting the agent's tool-result channel with +// DEBUG/INFO + ANSI escapes (62% of cascade-tools calls in the 2026-05-09 +// prod corpus). Strip the inherited tee BEFORE the singleton logger is +// constructed by the bootstrap import below. With LLMIST_LOG_FILE still set, +// every log line — including the load-bearing `[image-pipeline] +// work-item-fetch summary` per spec 016 — lands in the engine log the worker +// collects, so operator observability via `cascade runs logs ` is +// preserved. +delete process.env.LLMIST_LOG_TEE; +// Standalone CLI runs (no LLMIST_LOG_FILE inherited): redirect to /dev/null +// so dev runs stay envelope-only too. Override for debugging: +// `LLMIST_LOG_FILE=/tmp/x.log cascade-tools ...`. +if (!process.env.LLMIST_LOG_FILE) { + process.env.LLMIST_LOG_FILE = '/dev/null'; +} + import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; diff --git a/tests/unit/cli/cascade-tools-stdout-cleanliness.test.ts b/tests/unit/cli/cascade-tools-stdout-cleanliness.test.ts new file mode 100644 index 000000000..c232c1e27 --- /dev/null +++ b/tests/unit/cli/cascade-tools-stdout-cleanliness.test.ts @@ -0,0 +1,124 @@ +/** + * Pin: cascade-tools' stdout carries only the JSON envelope. + * + * Prod regression 2026-05-09 (62% of cascade-tools calls polluted): the + * worker process at `src/backends/llmist/index.ts:83-84` sets + * `LLMIST_LOG_FILE=` AND `LLMIST_LOG_TEE='true'` so its OWN + * logger tees writes to both the engine log file AND stdout. Both env vars + * are in the subprocess allowlist (`src/utils/cascadeEnv.ts:14-15`), so they + * pass through to the bash subprocess that runs cascade-tools — and the + * cascade-tools logger ALSO tees to stdout, polluting the agent's tool-result + * channel with DEBUG/INFO + ANSI escape codes before the JSON envelope. + * + * The fix at `bin/cascade-tools.js` strips the inherited tee BEFORE the + * logger singleton is constructed. With LLMIST_LOG_FILE still set, all log + * lines (including the load-bearing `[image-pipeline] work-item-fetch + * summary` per spec 016) land in the engine log the worker collects — + * operator observability via `cascade runs logs ` is preserved. + */ + +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +const REPO_ROOT = resolve(__dirname, '../../..'); +const BIN = resolve(REPO_ROOT, 'bin/cascade-tools.js'); +const DIST = resolve(REPO_ROOT, 'dist/cli/bootstrap.js'); + +// ESC byte (0x1b). Build at runtime so biome's `noControlCharactersInRegex` +// auto-fixer can't fold this back into a regex literal containing ESC. +const ESC = String.fromCharCode(0x1b); +const LOG_LEVEL_PREFIX = /\t(DEBUG|INFO|WARN|ERROR)\t/; +const ENVELOPE_START = /^\{"success":(true|false)/; + +let scratchDir: string; + +beforeEach(() => { + scratchDir = mkdtempSync(join(tmpdir(), 'cascade-tools-stdout-test-')); +}); + +afterEach(() => { + rmSync(scratchDir, { recursive: true, force: true }); +}); + +function runCascadeTools( + args: string[], + envOverrides: Record, +): { stdout: string; stderr: string; code: number | null } { + // Strip NODE_ENV — vitest sets it to 'test' which trips the integration + // entrypoint loaded by `bin/cascade-tools.js` and exits 2 without diagnostic. + // Unrelated to the stdout-cleanliness invariant under test. + const env: NodeJS.ProcessEnv = { ...process.env, ...envOverrides }; + delete env.NODE_ENV; + const result = spawnSync('node', [BIN, ...args], { + cwd: REPO_ROOT, + encoding: 'utf-8', + env, + timeout: 30_000, + }); + return { stdout: result.stdout ?? '', stderr: result.stderr ?? '', code: result.status }; +} + +describe('cascade-tools — stdout is reserved for the JSON envelope', () => { + const built = existsSync(DIST); + + it.skipIf(!built)( + 'under worker-shaped env (LLMIST_LOG_TEE=true + LLMIST_LOG_FILE=), stdout is envelope-only', + () => { + const engineLog = join(scratchDir, 'engine.log'); + // `pm read-work-item` against an obviously-fake workItemId hits a + // runtime failure inside the Trello/JIRA/Linear client, which emits + // `Fetching ... { ... }` debug lines BEFORE failing. Pre-fix, those + // debug lines land on stdout. Post-fix, they land in the engine log + // file (asserted in the next test). + const { stdout } = runCascadeTools( + ['pm', 'read-work-item', '--workItemId', 'NOT-A-REAL-WORK-ITEM'], + { LLMIST_LOG_TEE: 'true', LLMIST_LOG_FILE: engineLog }, + ); + + expect(stdout).toMatch(ENVELOPE_START); + expect(stdout).not.toContain(ESC); + expect(stdout).not.toMatch(LOG_LEVEL_PREFIX); + expect(stdout).not.toContain('[cascade]'); + }, + ); + + it.skipIf(!built)( + 'engine log file still receives logger output (operator observability preserved)', + () => { + const engineLog = join(scratchDir, 'engine.log'); + runCascadeTools(['pm', 'read-work-item', '--workItemId', 'NOT-A-REAL-WORK-ITEM'], { + LLMIST_LOG_TEE: 'true', + LLMIST_LOG_FILE: engineLog, + }); + + expect(existsSync(engineLog)).toBe(true); + const fileContent = readFileSync(engineLog, 'utf-8'); + // At least one cascade-emitted log line landed in the file. + expect(fileContent).toContain('[cascade]'); + }, + ); + + it.skipIf(!built)( + 'standalone CLI (no LLMIST_LOG_FILE inherited) keeps stdout envelope-only', + () => { + // Dev-style invocation — no engine log file in env. Pre-fix, llmist + // defaults to stdout; post-fix, the entrypoint redirects to /dev/null. + const env = { ...process.env }; + delete env.NODE_ENV; + delete env.LLMIST_LOG_FILE; + delete env.LLMIST_LOG_TEE; + const result = spawnSync( + 'node', + [BIN, 'pm', 'read-work-item', '--workItemId', 'NOT-A-REAL-WORK-ITEM'], + { cwd: REPO_ROOT, encoding: 'utf-8', env, timeout: 30_000 }, + ); + const stdout = result.stdout ?? ''; + expect(stdout).toMatch(ENVELOPE_START); + expect(stdout).not.toContain(ESC); + expect(stdout).not.toContain('[cascade]'); + }, + ); +}); From 93d566afa64674fae6fd925d0102f0924d53d30d Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Sat, 9 May 2026 11:28:44 +0000 Subject: [PATCH 17/18] fix(cascade-tools): drop dead github/trello topics with no CLI commands Remove `github` and `trello` from the explicit topics block in bin/cascade-tools.js and the matching test assertions. There are no src/cli/github/* or src/cli/trello/* commands in the source tree, so oclif filters these topics out of root help on a clean build. The test expectations only passed against stale dist artifacts, causing CI failures. Co-Authored-By: Claude Sonnet 4.6 --- bin/cascade-tools.js | 6 ------ tests/unit/cli/cascade-tools-help.test.ts | 4 ---- 2 files changed, 10 deletions(-) diff --git a/bin/cascade-tools.js b/bin/cascade-tools.js index f3e5d27ed..cdce0f20c 100755 --- a/bin/cascade-tools.js +++ b/bin/cascade-tools.js @@ -65,12 +65,6 @@ pjson.oclif = { }, alerting: { description: 'Inspect Sentry alerting issues and events.' }, session: { description: 'End the agent session. Exclusive terminal call.' }, - github: { - description: 'Direct GitHub provider commands. Prefer the provider-agnostic `scm` topic.', - }, - trello: { - description: 'Direct Trello provider commands. Prefer the provider-agnostic `pm` topic.', - }, }, }; diff --git a/tests/unit/cli/cascade-tools-help.test.ts b/tests/unit/cli/cascade-tools-help.test.ts index 4e5610829..397fd0efc 100644 --- a/tests/unit/cli/cascade-tools-help.test.ts +++ b/tests/unit/cli/cascade-tools-help.test.ts @@ -56,8 +56,6 @@ describe('cascade-tools --help — topic summaries', () => { expect(stdout).not.toMatch(/scm\s+Create a GitHub pull request\./); expect(stdout).not.toMatch(/alerting\s+Retrieve full details for an alerting event/); expect(stdout).not.toMatch(/session\s+Call this gadget when you have completed all tasks/); - expect(stdout).not.toMatch(/github\s+Create a GitHub pull request with optional commit/); - expect(stdout).not.toMatch(/trello\s+Add a checklist with items to a Trello card/); // Must contain canonical topic summaries for every discovered topic. expect(stdout).toContain('TOPICS'); @@ -65,8 +63,6 @@ describe('cascade-tools --help — topic summaries', () => { expect(stdout).toMatch(/scm\s+Interact with GitHub PRs/i); expect(stdout).toMatch(/alerting\s+Inspect Sentry alerting/i); expect(stdout).toMatch(/session\s+End the agent session/i); - expect(stdout).toMatch(/github\s+Direct GitHub provider commands/i); - expect(stdout).toMatch(/trello\s+Direct Trello provider commands/i); }); it.skipIf(!built)('per-gadget --help is unaffected (topic-summary fix is additive)', () => { From 2757cc91454efc8ed6b38bd5a9d0fd66b3a6b5dd Mon Sep 17 00:00:00 2001 From: aaight Date: Sat, 9 May 2026 13:31:38 +0200 Subject: [PATCH 18/18] refactor(triggers): share worker resolution and concurrency (#1286) Co-authored-by: Cascade Bot --- src/pm/webhook-handler.ts | 84 ++++++++----------- src/triggers/github/webhook-handler.ts | 44 ++++++---- src/triggers/sentry/webhook-handler.ts | 1 + src/triggers/shared/concurrency.ts | 12 ++- src/triggers/shared/trigger-resolution.ts | 26 +++++- tests/unit/pm/webhook-handler.test.ts | 6 ++ .../triggers/github-webhook-handler.test.ts | 14 ++++ .../triggers/sentry-webhook-handler.test.ts | 1 + .../unit/triggers/shared/concurrency.test.ts | 18 +++- .../shared/trigger-resolution.test.ts | 26 ++++++ 10 files changed, 157 insertions(+), 75 deletions(-) diff --git a/src/pm/webhook-handler.ts b/src/pm/webhook-handler.ts index 8548002c0..74da9cc2a 100644 --- a/src/pm/webhook-handler.ts +++ b/src/pm/webhook-handler.ts @@ -7,13 +7,9 @@ * ack comment management) is delegated to the PMIntegration interface. */ -import { - checkAgentTypeConcurrency, - clearAgentTypeEnqueued, - markAgentTypeEnqueued, - markRecentlyDispatched, -} from '../router/agent-type-lock.js'; import type { TriggerRegistry } from '../triggers/registry.js'; +import { withAgentTypeConcurrency } from '../triggers/shared/concurrency.js'; +import { resolveTriggerResult } from '../triggers/shared/trigger-resolution.js'; import { runAgentWithCredentials } from '../triggers/shared/webhook-execution.js'; import type { TriggerResult } from '../triggers/types.js'; import type { @@ -61,7 +57,7 @@ async function cleanupOrphanAck( } } -async function resolveTriggerResult( +async function resolvePMTriggerResult( integration: PMIntegration, registry: TriggerRegistry, payload: unknown, @@ -69,21 +65,14 @@ async function resolveTriggerResult( ackCommentId: string | undefined, preResolvedResult: TriggerResult | undefined, ): Promise { - if (preResolvedResult) { - logger.info(`Using pre-resolved trigger result for ${integration.type} webhook`, { - agentType: preResolvedResult.agentType, - }); - return preResolvedResult; - } const ctx: TriggerContext = { project, source: integration.type as TriggerSource, payload }; - const result = await registry.dispatch(ctx); - if (!result) { - logger.info(`No trigger matched for ${integration.type} webhook`); - if (ackCommentId) { + return resolveTriggerResult(registry, ctx, preResolvedResult, { + logLabel: `${integration.type} webhook`, + onNoMatch: async () => { + if (!ackCommentId) return; await cleanupOrphanAck(integration, project.id, payload, ackCommentId); - } - } - return result; + }, + }); } async function handleMatchedTrigger( @@ -95,7 +84,7 @@ async function handleMatchedTrigger( ackCommentId?: string, preResolvedResult?: TriggerResult, ): Promise { - const result = await resolveTriggerResult( + const result = await resolvePMTriggerResult( integration, registry, payload, @@ -110,44 +99,37 @@ async function handleMatchedTrigger( result.agentInput.ackCommentId = ackCommentId; } - // Agent-type concurrency limit - let agentTypeMaxConcurrency: number | null = null; - if (result.agentType) { - const concurrencyCheck = await checkAgentTypeConcurrency( - project.id, - result.agentType, - undefined, - result.workItemId, - ); - agentTypeMaxConcurrency = concurrencyCheck.maxConcurrency; - if (concurrencyCheck.blocked) return; - if (agentTypeMaxConcurrency !== null) { - markRecentlyDispatched(project.id, result.agentType, result.workItemId); - markAgentTypeEnqueued(project.id, result.agentType); - } - } - logger.info(`${integration.type} trigger matched`, { agentType: result.agentType, workItemId: result.workItemId, }); - startWatchdog(project.watchdogTimeoutMs); + const execute = async () => { + startWatchdog(project.watchdogTimeoutMs); - const pmConfig = resolveProjectPMConfig(project); - const lifecycle = new PMLifecycleManager(getPMProvider(), pmConfig); + const pmConfig = resolveProjectPMConfig(project); + const lifecycle = new PMLifecycleManager(getPMProvider(), pmConfig); - try { - await executeAgent(integration, result, project, config); - } catch (err) { - logger.error(`Failed to process ${integration.type} webhook`, { error: String(err) }); - if (result.workItemId) { - await lifecycle.handleError(result.workItemId, String(err)); - } - } finally { - if (result.agentType && agentTypeMaxConcurrency !== null) { - clearAgentTypeEnqueued(project.id, result.agentType); + try { + await executeAgent(integration, result, project, config); + } catch (err) { + logger.error(`Failed to process ${integration.type} webhook`, { error: String(err) }); + if (result.workItemId) { + await lifecycle.handleError(result.workItemId, String(err)); + } } + }; + + if (result.agentType) { + await withAgentTypeConcurrency( + project.id, + result.agentType, + execute, + `${integration.type} webhook`, + result.workItemId, + ); + } else { + await execute(); } } diff --git a/src/triggers/github/webhook-handler.ts b/src/triggers/github/webhook-handler.ts index fd87a2b1a..6ba7fd4be 100644 --- a/src/triggers/github/webhook-handler.ts +++ b/src/triggers/github/webhook-handler.ts @@ -22,6 +22,7 @@ import type { TriggerRegistry } from '../registry.js'; import { withAgentTypeConcurrency } from '../shared/concurrency.js'; import { withPMScope } from '../shared/credential-scope.js'; import { postPMAckComment } from '../shared/pm-ack.js'; +import { resolveTriggerResult } from '../shared/trigger-resolution.js'; import { runAgentWithCredentials } from '../shared/webhook-execution.js'; import type { TriggerResult } from '../types.js'; import { postAcknowledgmentComment, updateInitialCommentWithError } from './ack-comments.js'; @@ -78,17 +79,14 @@ async function maybePostPmAckComment( } } -/** Dispatch to trigger registry within PM credential + provider scope. */ -async function dispatchTrigger( - registry: TriggerRegistry, +/** Build a GitHub trigger context with persona identities for registry dispatch. */ +async function buildTriggerContext( payload: unknown, project: ProjectConfig, -): Promise { +): Promise { const projectId = requireProjectId(project); const personaIdentities = await resolvePersonaIdentities(projectId); - const githubToken = await getPersonaToken(projectId, 'implementation'); - const ctx: TriggerContext = { project, source: 'github', payload, personaIdentities }; - return withPMScope(project, () => withGitHubToken(githubToken, () => registry.dispatch(ctx))); + return { project, source: 'github', payload, personaIdentities }; } /** Post ack comment on the PR using the agent-specific persona token. */ @@ -174,7 +172,13 @@ async function runGitHubAgent( // Agent-type concurrency limit wraps the entire execution try { if (agentType) { - await withAgentTypeConcurrency(project.id, agentType, execute, 'GitHub agent'); + await withAgentTypeConcurrency( + project.id, + agentType, + execute, + 'GitHub agent', + result.workItemId, + ); } else { await execute(); } @@ -219,15 +223,21 @@ export async function processGitHubWebhook( } const { project, config } = projectConfig; - // Resolve trigger result — use pre-resolved from router or dispatch via registry - let result: TriggerResult | null; - if (triggerResult) { - logger.info('Using pre-resolved trigger result for GitHub webhook', { - agentType: triggerResult.agentType, - }); - result = triggerResult; - } else { - result = await dispatchTrigger(registry, payload, project); + const ctx = triggerResult + ? ({ project, source: 'github', payload } satisfies TriggerContext) + : await buildTriggerContext(payload, project); + + const result = await resolveTriggerResult(registry, ctx, triggerResult, { + logLabel: 'GitHub webhook', + dispatch: async (dispatchCtx) => { + const githubToken = await getPersonaToken(requireProjectId(project), 'implementation'); + return withPMScope(project, () => + withGitHubToken(githubToken, () => registry.dispatch(dispatchCtx)), + ); + }, + }); + + if (!triggerResult) { if (result?.deferredRecheck && isRecheckJob) { logger.warn('Mergeability still null after deferred re-check — giving up', { eventType }); captureException( diff --git a/src/triggers/sentry/webhook-handler.ts b/src/triggers/sentry/webhook-handler.ts index fa73329dd..ddc5abd3a 100644 --- a/src/triggers/sentry/webhook-handler.ts +++ b/src/triggers/sentry/webhook-handler.ts @@ -152,5 +152,6 @@ export async function processSentryWebhook( }); }, 'processSentryWebhook', + result.workItemId, ); } diff --git a/src/triggers/shared/concurrency.ts b/src/triggers/shared/concurrency.ts index b2414f69a..cdb361cd2 100644 --- a/src/triggers/shared/concurrency.ts +++ b/src/triggers/shared/concurrency.ts @@ -31,25 +31,33 @@ import { logger } from '../../utils/logging.js'; * @param agentType The agent type being dispatched. * @param fn The async function to run if not blocked. * @param logLabel Optional label for log messages (default: 'Agent'). + * @param workItemId Optional work-item scope for recent-dispatch dedup. */ export async function withAgentTypeConcurrency( projectId: string, agentType: string, fn: () => Promise, logLabel?: string, + workItemId?: string, ): Promise { - const concurrencyCheck = await checkAgentTypeConcurrency(projectId, agentType, logLabel); + const concurrencyCheck = await checkAgentTypeConcurrency( + projectId, + agentType, + logLabel, + workItemId, + ); if (concurrencyCheck.blocked) { logger.info(`${logLabel ?? 'Agent'} type concurrency blocked, skipping`, { projectId, agentType, + workItemId, }); return false; } const hasLimit = concurrencyCheck.maxConcurrency !== null; if (hasLimit) { - markRecentlyDispatched(projectId, agentType); + markRecentlyDispatched(projectId, agentType, workItemId); markAgentTypeEnqueued(projectId, agentType); } diff --git a/src/triggers/shared/trigger-resolution.ts b/src/triggers/shared/trigger-resolution.ts index 07ec264eb..1869fe76e 100644 --- a/src/triggers/shared/trigger-resolution.ts +++ b/src/triggers/shared/trigger-resolution.ts @@ -13,6 +13,22 @@ import type { TriggerContext, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; import type { TriggerRegistry } from '../registry.js'; +type TriggerDispatch = (ctx: TriggerContext) => Promise; + +interface ResolveTriggerResultOptions { + logLabel?: string; + dispatch?: TriggerDispatch; + onNoMatch?: () => Promise | void; +} + +function normalizeOptions( + optionsOrLogLabel?: string | ResolveTriggerResultOptions, +): ResolveTriggerResultOptions { + return typeof optionsOrLogLabel === 'string' + ? { logLabel: optionsOrLogLabel } + : (optionsOrLogLabel ?? {}); +} + /** * Resolve a trigger result from either a pre-computed result or registry dispatch. * @@ -22,16 +38,17 @@ import type { TriggerRegistry } from '../registry.js'; * @param registry Trigger registry to dispatch against (when no pre-resolved result). * @param ctx Trigger context passed to registry dispatch. * @param preResolvedResult Optional pre-computed result from the router (skips dispatch). - * @param logLabel Optional label for log messages (default: uses ctx.source). + * @param optionsOrLogLabel Optional label or dispatch/no-match hooks. * @returns The resolved TriggerResult, or null if no trigger matched. */ export async function resolveTriggerResult( registry: TriggerRegistry, ctx: TriggerContext, preResolvedResult?: TriggerResult, - logLabel?: string, + optionsOrLogLabel?: string | ResolveTriggerResultOptions, ): Promise { - const label = logLabel ?? ctx.source; + const options = normalizeOptions(optionsOrLogLabel); + const label = options.logLabel ?? ctx.source; if (preResolvedResult) { logger.info(`${label}: using pre-resolved trigger result`, { @@ -40,9 +57,10 @@ export async function resolveTriggerResult( return preResolvedResult; } - const result = await registry.dispatch(ctx); + const result = await (options.dispatch ?? ((dispatchCtx) => registry.dispatch(dispatchCtx)))(ctx); if (!result) { logger.info(`${label}: no trigger matched`); + await options.onNoMatch?.(); } return result; } diff --git a/tests/unit/pm/webhook-handler.test.ts b/tests/unit/pm/webhook-handler.test.ts index e3a5daa20..f13dbd4a7 100644 --- a/tests/unit/pm/webhook-handler.test.ts +++ b/tests/unit/pm/webhook-handler.test.ts @@ -220,6 +220,12 @@ describe('processPMWebhook', () => { await processPMWebhook(integration as never, { type: 'card_moved' }, registry as never); expect(mockRunAgentExecutionPipeline).not.toHaveBeenCalled(); + expect(checkAgentTypeConcurrency).toHaveBeenCalledWith( + 'project-1', + 'implementation', + 'trello webhook', + 'card-abc', + ); }); it('calls withCredentials on integration during execution', async () => { diff --git a/tests/unit/triggers/github-webhook-handler.test.ts b/tests/unit/triggers/github-webhook-handler.test.ts index 519c570e6..e9508cd3e 100644 --- a/tests/unit/triggers/github-webhook-handler.test.ts +++ b/tests/unit/triggers/github-webhook-handler.test.ts @@ -227,6 +227,20 @@ describe('processGitHubWebhook', () => { expect(mockRunAgentWithCredentials).not.toHaveBeenCalled(); }); + it('passes workItemId to shared concurrency helper', async () => { + const registry = createMockRegistry('implementation', 'card-abc'); + + await processGitHubWebhook(validPayload, 'pull_request', registry as never); + + expect(withAgentTypeConcurrency).toHaveBeenCalledWith( + 'project-1', + 'implementation', + expect.any(Function), + 'GitHub agent', + 'card-abc', + ); + }); + it('skips execution when no agentType in result', async () => { const registry = { dispatch: vi.fn().mockResolvedValue({ diff --git a/tests/unit/triggers/sentry-webhook-handler.test.ts b/tests/unit/triggers/sentry-webhook-handler.test.ts index 9cb051e3f..76c785ce7 100644 --- a/tests/unit/triggers/sentry-webhook-handler.test.ts +++ b/tests/unit/triggers/sentry-webhook-handler.test.ts @@ -194,6 +194,7 @@ describe('processSentryWebhook', () => { 'alerting', expect.any(Function), 'processSentryWebhook', + undefined, ); }); diff --git a/tests/unit/triggers/shared/concurrency.test.ts b/tests/unit/triggers/shared/concurrency.test.ts index 63a095c66..9d5a0b34a 100644 --- a/tests/unit/triggers/shared/concurrency.test.ts +++ b/tests/unit/triggers/shared/concurrency.test.ts @@ -78,10 +78,25 @@ describe('withAgentTypeConcurrency', () => { await withAgentTypeConcurrency(PROJECT_ID, AGENT_TYPE, fn); - expect(mockMarkRecentlyDispatched).toHaveBeenCalledWith(PROJECT_ID, AGENT_TYPE); + expect(mockMarkRecentlyDispatched).toHaveBeenCalledWith(PROJECT_ID, AGENT_TYPE, undefined); expect(mockMarkAgentTypeEnqueued).toHaveBeenCalledWith(PROJECT_ID, AGENT_TYPE); }); + it('passes workItemId through concurrency check and recent-dispatch dedup', async () => { + const fn = vi.fn().mockResolvedValue(undefined); + mockCheckAgentTypeConcurrency.mockResolvedValue({ maxConcurrency: 2, blocked: false }); + + await withAgentTypeConcurrency(PROJECT_ID, AGENT_TYPE, fn, 'My handler', 'card-123'); + + expect(mockCheckAgentTypeConcurrency).toHaveBeenCalledWith( + PROJECT_ID, + AGENT_TYPE, + 'My handler', + 'card-123', + ); + expect(mockMarkRecentlyDispatched).toHaveBeenCalledWith(PROJECT_ID, AGENT_TYPE, 'card-123'); + }); + it('does not mark enqueued when no limit (maxConcurrency null)', async () => { const fn = vi.fn().mockResolvedValue(undefined); mockCheckAgentTypeConcurrency.mockResolvedValue({ maxConcurrency: null, blocked: false }); @@ -140,6 +155,7 @@ describe('withAgentTypeConcurrency', () => { PROJECT_ID, AGENT_TYPE, 'My handler', + undefined, ); }); }); diff --git a/tests/unit/triggers/shared/trigger-resolution.test.ts b/tests/unit/triggers/shared/trigger-resolution.test.ts index ffa5ff6be..448d6b21b 100644 --- a/tests/unit/triggers/shared/trigger-resolution.test.ts +++ b/tests/unit/triggers/shared/trigger-resolution.test.ts @@ -127,4 +127,30 @@ describe('resolveTriggerResult', () => { expect(mockRegistry.dispatch).toHaveBeenCalledOnce(); expect(result).toBe(triggerResult); }); + + it('uses custom dispatch when provided', async () => { + const dispatch = vi.fn().mockResolvedValue(triggerResult); + + const result = await resolveTriggerResult(mockRegistry as never, ctx, undefined, { + logLabel: 'CustomDispatch', + dispatch, + }); + + expect(dispatch).toHaveBeenCalledWith(ctx); + expect(mockRegistry.dispatch).not.toHaveBeenCalled(); + expect(result).toBe(triggerResult); + }); + + it('runs onNoMatch when custom dispatch returns null', async () => { + const onNoMatch = vi.fn().mockResolvedValue(undefined); + + const result = await resolveTriggerResult(mockRegistry as never, ctx, undefined, { + logLabel: 'CustomDispatch', + dispatch: vi.fn().mockResolvedValue(null), + onNoMatch, + }); + + expect(result).toBeNull(); + expect(onNoMatch).toHaveBeenCalledOnce(); + }); });