From d24fd805666479443f8e9c82218722b90b3d0f71 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Mon, 16 Mar 2026 13:14:40 +0000 Subject: [PATCH 1/5] feat(agents): require explicit agent config to enable agent per project --- src/agents/definitions/backlog-manager.yaml | 2 +- src/agents/definitions/implementation.yaml | 4 +- src/agents/definitions/planning.yaml | 6 +- src/agents/definitions/resolve-conflicts.yaml | 2 +- src/agents/definitions/respond-to-ci.yaml | 2 +- .../respond-to-planning-comment.yaml | 2 +- .../definitions/respond-to-pr-comment.yaml | 2 +- src/agents/definitions/respond-to-review.yaml | 2 +- src/agents/definitions/review.yaml | 4 +- src/agents/definitions/schema.ts | 2 +- src/agents/definitions/splitting.yaml | 4 +- src/api/routers/_shared/triggerTypes.ts | 10 ++ src/api/routers/agentTriggerConfigs.ts | 37 +++-- src/api/routers/runs.ts | 10 ++ src/cli/dashboard/agents/list.ts | 8 +- src/db/repositories/agentConfigsRepository.ts | 42 +++++ src/triggers/config-resolver.ts | 25 +++ src/triggers/shared/manual-runner.ts | 9 + tests/unit/agents/definitions/loader.test.ts | 4 +- ...ggerConfigs.getProjectTriggersView.test.ts | 59 +++++-- tests/unit/api/routers/runs.test.ts | 20 +++ tests/unit/triggers/config-resolver.test.ts | 66 +++++++- tests/unit/triggers/manual-runner.test.ts | 22 +++ .../projects/project-agent-configs.tsx | 157 +++++++++++++----- 24 files changed, 406 insertions(+), 95 deletions(-) diff --git a/src/agents/definitions/backlog-manager.yaml b/src/agents/definitions/backlog-manager.yaml index 3ba81f1f..f1d0ada4 100644 --- a/src/agents/definitions/backlog-manager.yaml +++ b/src/agents/definitions/backlog-manager.yaml @@ -42,7 +42,7 @@ triggers: - event: internal:auto-chain label: Auto-chain after Splitting description: When splitting completes on a card with the auto label, immediately chain to backlog manager - defaultEnabled: true + defaultEnabled: false contextPipeline: [pipelineSnapshot] strategies: {} diff --git a/src/agents/definitions/implementation.yaml b/src/agents/definitions/implementation.yaml index 0e7bee91..912f99d6 100644 --- a/src/agents/definitions/implementation.yaml +++ b/src/agents/definitions/implementation.yaml @@ -28,7 +28,7 @@ triggers: - event: pm:status-changed label: Status Changed to Todo description: Trigger when work item status changes to Todo - defaultEnabled: true + defaultEnabled: false parameters: - name: targetStatus type: select @@ -39,7 +39,7 @@ triggers: - event: pm:label-added label: Ready to Process Label description: Trigger when Ready to Process label added to a card in the Todo list - defaultEnabled: true + defaultEnabled: false parameters: - name: listKey type: select diff --git a/src/agents/definitions/planning.yaml b/src/agents/definitions/planning.yaml index 950a510a..7ea28d07 100644 --- a/src/agents/definitions/planning.yaml +++ b/src/agents/definitions/planning.yaml @@ -25,7 +25,7 @@ triggers: - event: pm:status-changed label: Status Changed to Planning description: Trigger when work item status changes to Planning - defaultEnabled: true + defaultEnabled: false parameters: - name: targetStatus type: select @@ -36,7 +36,7 @@ triggers: - event: pm:label-added label: Ready to Process Label description: Trigger when Ready to Process label added to a card in Planning list - defaultEnabled: true + defaultEnabled: false parameters: - name: listKey type: select @@ -47,7 +47,7 @@ triggers: - event: pm:comment-mention label: Comment @mention description: Trigger when bot is @mentioned in a card/issue comment - defaultEnabled: true + defaultEnabled: false contextPipeline: [directoryListing, contextFiles, squint, workItem] strategies: {} diff --git a/src/agents/definitions/resolve-conflicts.yaml b/src/agents/definitions/resolve-conflicts.yaml index 10e38bff..c7a7591f 100644 --- a/src/agents/definitions/resolve-conflicts.yaml +++ b/src/agents/definitions/resolve-conflicts.yaml @@ -28,7 +28,7 @@ triggers: - event: scm:pr-conflict-detected label: PR Conflict Detected description: Trigger when a PR has merge conflicts with the base branch - defaultEnabled: true + defaultEnabled: false providers: [github] contextPipeline: [prContext, directoryListing, contextFiles, squint, workItem] diff --git a/src/agents/definitions/respond-to-ci.yaml b/src/agents/definitions/respond-to-ci.yaml index 80068950..23d64ae9 100644 --- a/src/agents/definitions/respond-to-ci.yaml +++ b/src/agents/definitions/respond-to-ci.yaml @@ -29,7 +29,7 @@ triggers: - event: scm:check-suite-failure label: Check Suite Failure description: Trigger when CI checks fail - defaultEnabled: true + defaultEnabled: false providers: [github] contextPipeline: [prContext, directoryListing, contextFiles, squint, workItem] diff --git a/src/agents/definitions/respond-to-planning-comment.yaml b/src/agents/definitions/respond-to-planning-comment.yaml index db223d54..b2f1db7a 100644 --- a/src/agents/definitions/respond-to-planning-comment.yaml +++ b/src/agents/definitions/respond-to-planning-comment.yaml @@ -26,7 +26,7 @@ triggers: - event: pm:comment-mention label: Comment @mention description: Trigger when bot is @mentioned in a card/issue comment - defaultEnabled: true + defaultEnabled: false contextPipeline: [directoryListing, contextFiles, squint, workItem] strategies: {} diff --git a/src/agents/definitions/respond-to-pr-comment.yaml b/src/agents/definitions/respond-to-pr-comment.yaml index 7408d28e..b962b80c 100644 --- a/src/agents/definitions/respond-to-pr-comment.yaml +++ b/src/agents/definitions/respond-to-pr-comment.yaml @@ -27,7 +27,7 @@ triggers: - event: scm:pr-comment-mention label: PR Comment @mention description: Trigger when the implementer bot is @mentioned in a PR comment - defaultEnabled: true + defaultEnabled: false providers: [github] contextPipeline: [prContext, prConversation, directoryListing, contextFiles, squint] diff --git a/src/agents/definitions/respond-to-review.yaml b/src/agents/definitions/respond-to-review.yaml index 061187f8..5a768c83 100644 --- a/src/agents/definitions/respond-to-review.yaml +++ b/src/agents/definitions/respond-to-review.yaml @@ -28,7 +28,7 @@ triggers: - event: scm:pr-review-submitted label: PR Review Submitted description: Trigger when a review with changes requested or comments is submitted - defaultEnabled: true + defaultEnabled: false providers: [github] contextPipeline: [prContext, prConversation, directoryListing, contextFiles, squint] diff --git a/src/agents/definitions/review.yaml b/src/agents/definitions/review.yaml index bedb64a5..3bd11888 100644 --- a/src/agents/definitions/review.yaml +++ b/src/agents/definitions/review.yaml @@ -59,13 +59,13 @@ triggers: - event: scm:pr-ready-to-merge label: PR Ready to Merge description: Move work item to DONE when PR is approved and all checks pass - defaultEnabled: true + defaultEnabled: false providers: [github] contextPipeline: [] - event: scm:pr-merged label: PR Merged description: Move work item to MERGED status when PR is merged - defaultEnabled: true + defaultEnabled: false providers: [github] contextPipeline: [] diff --git a/src/agents/definitions/schema.ts b/src/agents/definitions/schema.ts index 9dafd15e..16492314 100644 --- a/src/agents/definitions/schema.ts +++ b/src/agents/definitions/schema.ts @@ -107,7 +107,7 @@ export const SupportedTriggerSchema = z.object({ /** Optional description for help text */ description: z.string().optional(), /** Whether the trigger is enabled by default */ - defaultEnabled: z.boolean().default(true), + defaultEnabled: z.boolean().default(false), /** Configurable parameters for this trigger */ parameters: z.array(TriggerParameterSchema).default([]), /** Provider filter - only applies to these providers (e.g., ['trello']) */ diff --git a/src/agents/definitions/splitting.yaml b/src/agents/definitions/splitting.yaml index 974918c8..e8dfa87e 100644 --- a/src/agents/definitions/splitting.yaml +++ b/src/agents/definitions/splitting.yaml @@ -26,7 +26,7 @@ triggers: - event: pm:status-changed label: Status Changed to Splitting description: Trigger when work item status changes to Splitting - defaultEnabled: true + defaultEnabled: false parameters: - name: targetStatus type: select @@ -37,7 +37,7 @@ triggers: - event: pm:label-added label: Ready to Process Label description: Trigger when Ready to Process label added to a card in Splitting list - defaultEnabled: true + defaultEnabled: false parameters: - name: listKey type: select diff --git a/src/api/routers/_shared/triggerTypes.ts b/src/api/routers/_shared/triggerTypes.ts index 29218544..1b8f1e23 100644 --- a/src/api/routers/_shared/triggerTypes.ts +++ b/src/api/routers/_shared/triggerTypes.ts @@ -82,9 +82,19 @@ export interface ProjectIntegrationsMap { /** * Complete triggers view for a project. * Response type for getProjectTriggersView. + * + * `enabledAgents` — agents that have an explicit agent_configs row (opt-in enabled). + * `availableAgents` — agents that exist in definitions but are NOT yet configured. + * + * The legacy `agents` field equals `enabledAgents` for backwards compatibility. */ export interface ProjectTriggersView { + /** @deprecated Use enabledAgents instead */ agents: AgentTriggersView[]; + /** Agents with an explicit agent_configs row — actively configured for this project */ + enabledAgents: AgentTriggersView[]; + /** Agent types defined in YAML/DB but not yet configured for this project */ + availableAgents: string[]; integrations: ProjectIntegrationsMap; } diff --git a/src/api/routers/agentTriggerConfigs.ts b/src/api/routers/agentTriggerConfigs.ts index 7c94ee5a..65812ccf 100644 --- a/src/api/routers/agentTriggerConfigs.ts +++ b/src/api/routers/agentTriggerConfigs.ts @@ -6,6 +6,7 @@ import type { SupportedTrigger, TriggerParameter, } from '../../agents/definitions/schema.js'; +import { listAgentConfigs } from '../../db/repositories/agentConfigsRepository.js'; import { listAgentDefinitions } from '../../db/repositories/agentDefinitionsRepository.js'; import { deleteTriggerConfig, @@ -204,16 +205,20 @@ export const agentTriggerConfigsRouter = router({ .query(async ({ ctx, input }): Promise => { await verifyProjectOrgAccess(input.projectId, ctx.effectiveOrgId); - // Fetch DB definitions and configs in parallel - const [dbDefinitions, configs, integrations] = await Promise.all([ + // Fetch DB definitions, trigger configs, agent configs (for enabled check), and integrations + const [dbDefinitions, configs, projectAgentConfigs, integrations] = await Promise.all([ listAgentDefinitions().catch((err) => { logger.warn('Failed to fetch agent definitions from DB', { error: err }); return []; }), getTriggerConfigsByProject(input.projectId), + listAgentConfigs({ projectId: input.projectId }), listProjectIntegrations(input.projectId), ]); + // Build set of explicitly enabled agent types for this project + const enabledAgentTypes = new Set(projectAgentConfigs.map((c) => c.agentType)); + // Build a combined list of definitions (DB + YAML) const yamlTypes = getKnownAgentTypes(); const definitions: Array<{ agentType: string; definition: AgentDefinition }> = []; @@ -280,12 +285,12 @@ export const agentTriggerConfigsRouter = router({ }; } - // Build the agents array with merged trigger data - const agents = definitions.map((def) => { - const agentConfigs = configMap.get(def.agentType); + // Build merged trigger data for a definition + function buildAgentTriggersView(def: { agentType: string; definition: AgentDefinition }) { + const agentTriggerConfigs = configMap.get(def.agentType); const triggers: ResolvedTrigger[] = (def.definition.triggers ?? []).map( (trigger: SupportedTrigger) => { - const config = agentConfigs?.get(trigger.event); + const config = agentTriggerConfigs?.get(trigger.event); return { event: trigger.event, label: trigger.label, @@ -301,12 +306,18 @@ export const agentTriggerConfigsRouter = router({ }; }, ); + return { agentType: def.agentType, triggers }; + } - return { - agentType: def.agentType, - triggers, - }; - }); + // Split definitions into enabled (have agent_configs row) and available (no row) + // The debug agent is always shown as enabled (internal infrastructure) + const enabledAgents = definitions + .filter((def) => enabledAgentTypes.has(def.agentType) || def.agentType === 'debug') + .map(buildAgentTriggersView); + + const availableAgents = definitions + .filter((def) => !enabledAgentTypes.has(def.agentType) && def.agentType !== 'debug') + .map((def) => def.agentType); // Build integrations map with single pass const integrationsMap = { @@ -321,7 +332,9 @@ export const agentTriggerConfigsRouter = router({ } return { - agents, + agents: enabledAgents, // backwards compat: same as enabledAgents + enabledAgents, + availableAgents, integrations: integrationsMap, }; }), diff --git a/src/api/routers/runs.ts b/src/api/routers/runs.ts index 7f70ca2e..374ca937 100644 --- a/src/api/routers/runs.ts +++ b/src/api/routers/runs.ts @@ -1,6 +1,7 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { loadProjectConfigById } from '../../config/provider.js'; +import { isAgentEnabledForProject } from '../../db/repositories/agentConfigsRepository.js'; import { DEFAULT_STALE_RUN_THRESHOLD_MS, cancelRunById, @@ -274,6 +275,15 @@ export const runsRouter = router({ }); } + // Check agent is explicitly enabled for this project + const agentEnabled = await isAgentEnabledForProject(input.projectId, input.agentType); + if (!agentEnabled) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Agent '${input.agentType}' is not enabled for this project. Add an agent config in Project Settings > Agent Configs to enable it.`, + }); + } + if (useQueue) { const { submitDashboardJob } = await import('../../queue/client.js'); await submitDashboardJob({ diff --git a/src/cli/dashboard/agents/list.ts b/src/cli/dashboard/agents/list.ts index 71422313..b8802229 100644 --- a/src/cli/dashboard/agents/list.ts +++ b/src/cli/dashboard/agents/list.ts @@ -2,7 +2,8 @@ import { Flags } from '@oclif/core'; import { DashboardCommand } from '../_shared/base.js'; export default class AgentsList extends DashboardCommand { - static override description = 'List agent configurations for a project.'; + static override description = + 'List enabled agent configurations for a project. Only agents with an explicit config row are shown (opt-in required).'; static override flags = { ...DashboardCommand.baseFlags, @@ -22,6 +23,11 @@ export default class AgentsList extends DashboardCommand { return; } + if (configs.length === 0) { + this.log('No agents enabled for this project. Use `cascade agents create` to enable one.'); + return; + } + this.outputTable(configs as unknown as Record[], [ { key: 'id', header: 'ID' }, { key: 'agentType', header: 'Agent Type' }, diff --git a/src/db/repositories/agentConfigsRepository.ts b/src/db/repositories/agentConfigsRepository.ts index 85448c3d..54ee95ee 100644 --- a/src/db/repositories/agentConfigsRepository.ts +++ b/src/db/repositories/agentConfigsRepository.ts @@ -116,6 +116,48 @@ export async function getAgentConfigPrompts( return result; } +/** + * Check whether an agent is explicitly enabled for a project. + * An agent is enabled if and only if it has a row in `agent_configs` for that project. + * The `debug` agent is always considered enabled (internal infrastructure). + * + * Results are cached for 5 seconds to avoid repeated DB queries on + * sequential webhook batches. + */ +const AGENT_ENABLED_TTL_MS = 5_000; +const agentEnabledCache = new Map(); + +export async function isAgentEnabledForProject( + projectId: string, + agentType: string, +): Promise { + // Debug agent is always enabled — internal infrastructure agent + if (agentType === 'debug') { + return true; + } + + const cacheKey = `${projectId}:${agentType}`; + const cached = agentEnabledCache.get(cacheKey); + if (cached && Date.now() < cached.expiresAt) { + return cached.value; + } + + const db = getDb(); + + const [row] = await db + .select({ id: agentConfigs.id }) + .from(agentConfigs) + .where(and(eq(agentConfigs.projectId, projectId), eq(agentConfigs.agentType, agentType))) + .limit(1); + + const result = row !== undefined; + agentEnabledCache.set(cacheKey, { + value: result, + expiresAt: Date.now() + AGENT_ENABLED_TTL_MS, + }); + return result; +} + /** * Resolve max_concurrency for a (projectId, agentType) pair. * Returns null if no project-scoped config with max_concurrency is found (= no limit). diff --git a/src/triggers/config-resolver.ts b/src/triggers/config-resolver.ts index 94397113..df207f24 100644 --- a/src/triggers/config-resolver.ts +++ b/src/triggers/config-resolver.ts @@ -38,6 +38,7 @@ import { resolveAgentDefinition } from '../agents/definitions/index.js'; import type { SupportedTrigger } from '../agents/definitions/schema.js'; +import { isAgentEnabledForProject } from '../db/repositories/agentConfigsRepository.js'; import { type AgentTriggerConfig, getTriggerConfig, @@ -77,6 +78,12 @@ export async function resolveTriggerConfigs( projectId: string, agentType: string, ): Promise { + // Gate on agent config existence — agent must be explicitly enabled + const enabled = await isAgentEnabledForProject(projectId, agentType); + if (!enabled) { + return []; + } + // Get definition triggers const definition = await resolveAgentDefinition(agentType); if (!definition) { @@ -111,6 +118,12 @@ export async function isTriggerEnabled( agentType: string, triggerEvent: string, ): Promise { + // Gate on agent config existence — agent must be explicitly enabled + const agentEnabled = await isAgentEnabledForProject(projectId, agentType); + if (!agentEnabled) { + return false; + } + // First check DB override const dbConfig = await getTriggerConfig(projectId, agentType, triggerEvent); if (dbConfig) { @@ -140,6 +153,12 @@ export async function getTriggerParameters( agentType: string, triggerEvent: string, ): Promise> { + // Gate on agent config existence — agent must be explicitly enabled + const agentEnabled = await isAgentEnabledForProject(projectId, agentType); + if (!agentEnabled) { + return {}; + } + const definition = await resolveAgentDefinition(agentType); if (!definition) { return {}; @@ -176,6 +195,12 @@ export async function getResolvedTriggerConfig( agentType: string, triggerEvent: string, ): Promise { + // Gate on agent config existence — agent must be explicitly enabled + const agentEnabled = await isAgentEnabledForProject(projectId, agentType); + if (!agentEnabled) { + return null; + } + const definition = await resolveAgentDefinition(agentType); if (!definition) { return null; diff --git a/src/triggers/shared/manual-runner.ts b/src/triggers/shared/manual-runner.ts index e3862763..715507a0 100644 --- a/src/triggers/shared/manual-runner.ts +++ b/src/triggers/shared/manual-runner.ts @@ -1,4 +1,5 @@ import { runAgent } from '../../agents/registry.js'; +import { isAgentEnabledForProject } from '../../db/repositories/agentConfigsRepository.js'; import { getRunById } from '../../db/repositories/runsRepository.js'; import { withPMCredentials } from '../../pm/context.js'; import { createPMProvider, pmRegistry, withPMProvider } from '../../pm/index.js'; @@ -79,6 +80,14 @@ export async function triggerManualRun( ); } + // Check agent is explicitly enabled for this project + const agentEnabled = await isAgentEnabledForProject(input.projectId, input.agentType); + if (!agentEnabled) { + throw new Error( + `Agent '${input.agentType}' is not enabled for project '${input.projectId}'. Add an agent config in Project Settings > Agent Configs to enable it.`, + ); + } + // Pre-flight integration validation const validation = await validateIntegrations(input.projectId, input.agentType); if (!validation.valid) { diff --git a/tests/unit/agents/definitions/loader.test.ts b/tests/unit/agents/definitions/loader.test.ts index 807d1655..f5c747cb 100644 --- a/tests/unit/agents/definitions/loader.test.ts +++ b/tests/unit/agents/definitions/loader.test.ts @@ -279,10 +279,10 @@ describe('YAML agent definitions loader', () => { } }); - it('backlog-manager internal:auto-chain trigger is defaultEnabled: true', () => { + it('backlog-manager internal:auto-chain trigger is defaultEnabled: false (all triggers off by default)', () => { const def = loadAgentDefinition('backlog-manager'); const autoChainTrigger = def.triggers.find((t) => t.event === 'internal:auto-chain'); - expect(autoChainTrigger?.defaultEnabled).toBe(true); + expect(autoChainTrigger?.defaultEnabled).toBe(false); }); it('backlog-manager requires only pm integration', () => { diff --git a/tests/unit/api/routers/agentTriggerConfigs.getProjectTriggersView.test.ts b/tests/unit/api/routers/agentTriggerConfigs.getProjectTriggersView.test.ts index 07a279d8..653414d1 100644 --- a/tests/unit/api/routers/agentTriggerConfigs.getProjectTriggersView.test.ts +++ b/tests/unit/api/routers/agentTriggerConfigs.getProjectTriggersView.test.ts @@ -11,6 +11,7 @@ const mockGetTriggerConfigsByProject = vi.fn(); const mockListProjectIntegrations = vi.fn(); const mockGetKnownAgentTypes = vi.fn(); const mockLoadAgentDefinition = vi.fn(); +const mockListAgentConfigs = vi.fn(); vi.mock('../../../../src/db/repositories/agentDefinitionsRepository.js', () => ({ listAgentDefinitions: (...args: unknown[]) => mockListAgentDefinitions(...args), @@ -30,6 +31,11 @@ vi.mock('../../../../src/db/repositories/settingsRepository.js', () => ({ listProjectIntegrations: (...args: unknown[]) => mockListProjectIntegrations(...args), })); +vi.mock('../../../../src/db/repositories/agentConfigsRepository.js', () => ({ + listAgentConfigs: (...args: unknown[]) => mockListAgentConfigs(...args), + isAgentEnabledForProject: vi.fn().mockResolvedValue(true), +})); + vi.mock('../../../../src/agents/definitions/loader.js', () => ({ getKnownAgentTypes: (...args: unknown[]) => mockGetKnownAgentTypes(...args), loadAgentDefinition: (...args: unknown[]) => mockLoadAgentDefinition(...args), @@ -86,6 +92,8 @@ describe('agentTriggerConfigsRouter — getProjectTriggersView', () => { mockListAgentDefinitions.mockResolvedValue([]); mockGetKnownAgentTypes.mockReturnValue([]); mockLoadAgentDefinition.mockReturnValue(makeAgentDefinition()); + // Default: no agent configs (all agents are unconfigured / available) + mockListAgentConfigs.mockResolvedValue([]); }); it('throws UNAUTHORIZED when not authenticated', async () => { @@ -95,17 +103,32 @@ describe('agentTriggerConfigsRouter — getProjectTriggersView', () => { ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); }); - it('returns empty agents and null integrations when nothing is configured', async () => { + it('returns empty enabledAgents and null integrations when nothing is configured', async () => { const caller = createCaller(mockCtx); const result = await caller.getProjectTriggersView({ projectId: 'test-project' }); - expect(result.agents).toEqual([]); + expect(result.enabledAgents).toEqual([]); + expect(result.agents).toEqual([]); // backwards compat alias expect(result.integrations).toEqual({ pm: null, scm: null }); }); + it('returns availableAgents for unconfigured agent types', async () => { + const definition = makeAgentDefinition(); + mockListAgentDefinitions.mockResolvedValue([{ agentType: 'implementation', definition }]); + // No agent_configs row → implementation should be in availableAgents, not enabledAgents + + const caller = createCaller(mockCtx); + const result = await caller.getProjectTriggersView({ projectId: 'test-project' }); + + expect(result.enabledAgents).toHaveLength(0); + expect(result.availableAgents).toContain('implementation'); + }); + it('merges DB definitions with project trigger configs', async () => { const definition = makeAgentDefinition(); mockListAgentDefinitions.mockResolvedValue([{ agentType: 'implementation', definition }]); + // Agent has an agent_configs row → it is enabled + mockListAgentConfigs.mockResolvedValue([{ agentType: 'implementation', id: 1 }]); mockGetTriggerConfigsByProject.mockResolvedValue([ { agentType: 'implementation', @@ -118,16 +141,18 @@ describe('agentTriggerConfigsRouter — getProjectTriggersView', () => { const caller = createCaller(mockCtx); const result = await caller.getProjectTriggersView({ projectId: 'test-project' }); - expect(result.agents).toHaveLength(1); + expect(result.enabledAgents).toHaveLength(1); + expect(result.agents).toHaveLength(1); // backwards compat expect(result.agents[0].agentType).toBe('implementation'); expect(result.agents[0].triggers[0].event).toBe('pm:status-changed'); expect(result.agents[0].triggers[0].enabled).toBe(true); expect(result.agents[0].triggers[0].isCustomized).toBe(true); }); - it('uses defaultEnabled when no config exists (isCustomized=false)', async () => { + it('uses defaultEnabled when no trigger config exists (isCustomized=false)', async () => { const definition = makeAgentDefinition(); mockListAgentDefinitions.mockResolvedValue([{ agentType: 'implementation', definition }]); + mockListAgentConfigs.mockResolvedValue([{ agentType: 'implementation', id: 1 }]); // No trigger configs const caller = createCaller(mockCtx); @@ -164,6 +189,7 @@ describe('agentTriggerConfigsRouter — getProjectTriggersView', () => { mockListAgentDefinitions.mockResolvedValue([ { agentType: 'review', definition: definitionWithParams }, ]); + mockListAgentConfigs.mockResolvedValue([{ agentType: 'review', id: 2 }]); mockGetTriggerConfigsByProject.mockResolvedValue([ { agentType: 'review', @@ -209,6 +235,7 @@ describe('agentTriggerConfigsRouter — getProjectTriggersView', () => { mockListAgentDefinitions.mockResolvedValue([ { agentType: 'review', definition: definitionWithParams }, ]); + mockListAgentConfigs.mockResolvedValue([{ agentType: 'review', id: 2 }]); mockGetTriggerConfigsByProject.mockResolvedValue([ { agentType: 'review', @@ -253,12 +280,13 @@ describe('agentTriggerConfigsRouter — getProjectTriggersView', () => { // Falls back to YAML — need some types for that mockGetKnownAgentTypes.mockReturnValue(['implementation']); mockLoadAgentDefinition.mockReturnValue(makeAgentDefinition()); + mockListAgentConfigs.mockResolvedValue([{ agentType: 'implementation', id: 1 }]); const caller = createCaller(mockCtx); const result = await caller.getProjectTriggersView({ projectId: 'test-project' }); - // Should not throw; falls back to YAML - expect(result.agents).toHaveLength(1); + // Should not throw; falls back to YAML, and shows as enabled since it has a config + expect(result.enabledAgents).toHaveLength(1); expect(result.agents[0].agentType).toBe('implementation'); }); @@ -267,6 +295,7 @@ describe('agentTriggerConfigsRouter — getProjectTriggersView', () => { mockListAgentDefinitions.mockResolvedValue([{ agentType: 'implementation', definition }]); // YAML also has 'implementation' mockGetKnownAgentTypes.mockReturnValue(['implementation']); + mockListAgentConfigs.mockResolvedValue([{ agentType: 'implementation', id: 1 }]); const caller = createCaller(mockCtx); const result = await caller.getProjectTriggersView({ projectId: 'test-project' }); @@ -275,17 +304,20 @@ describe('agentTriggerConfigsRouter — getProjectTriggersView', () => { expect(result.agents).toHaveLength(1); }); - it('includes YAML-only agents not in DB', async () => { + it('enabled agents appear in enabledAgents; unconfigured appear in availableAgents', async () => { mockListAgentDefinitions.mockResolvedValue([]); // no DB definitions mockGetKnownAgentTypes.mockReturnValue(['splitting', 'planning']); mockLoadAgentDefinition.mockReturnValue(makeAgentDefinition()); + // Only 'splitting' is enabled + mockListAgentConfigs.mockResolvedValue([{ agentType: 'splitting', id: 1 }]); const caller = createCaller(mockCtx); const result = await caller.getProjectTriggersView({ projectId: 'test-project' }); - expect(result.agents).toHaveLength(2); - expect(result.agents.map((a) => a.agentType)).toContain('splitting'); - expect(result.agents.map((a) => a.agentType)).toContain('planning'); + expect(result.enabledAgents).toHaveLength(1); + expect(result.enabledAgents[0].agentType).toBe('splitting'); + expect(result.availableAgents).toContain('planning'); + expect(result.availableAgents).not.toContain('splitting'); }); it('handles YAML load failure gracefully (skips that agent)', async () => { @@ -295,11 +327,12 @@ describe('agentTriggerConfigsRouter — getProjectTriggersView', () => { .mockImplementationOnce(() => { throw new Error('YAML parse error'); }); + mockListAgentConfigs.mockResolvedValue([{ agentType: 'implementation', id: 1 }]); const caller = createCaller(mockCtx); const result = await caller.getProjectTriggersView({ projectId: 'test-project' }); - // 'failing-agent' should be skipped; 'implementation' included + // 'failing-agent' should be skipped; 'implementation' included in enabled expect(result.agents).toHaveLength(1); expect(result.agents[0].agentType).toBe('implementation'); }); @@ -312,7 +345,7 @@ describe('agentTriggerConfigsRouter — getProjectTriggersView', () => { label: 'Status Changed', description: 'When status changes', providers: null, - defaultEnabled: true, + defaultEnabled: false, parameters: [ { name: 'myParam', @@ -328,6 +361,7 @@ describe('agentTriggerConfigsRouter — getProjectTriggersView', () => { ], }; mockListAgentDefinitions.mockResolvedValue([{ agentType: 'implementation', definition }]); + mockListAgentConfigs.mockResolvedValue([{ agentType: 'implementation', id: 1 }]); const caller = createCaller(mockCtx); const result = await caller.getProjectTriggersView({ projectId: 'test-project' }); @@ -345,6 +379,7 @@ describe('agentTriggerConfigsRouter — getProjectTriggersView', () => { it('handles trigger with no parameters (empty parameterDefs and parameters)', async () => { const definition = makeAgentDefinition(); mockListAgentDefinitions.mockResolvedValue([{ agentType: 'implementation', definition }]); + mockListAgentConfigs.mockResolvedValue([{ agentType: 'implementation', id: 1 }]); const caller = createCaller(mockCtx); const result = await caller.getProjectTriggersView({ projectId: 'test-project' }); diff --git a/tests/unit/api/routers/runs.test.ts b/tests/unit/api/routers/runs.test.ts index 7a49b96f..68f39087 100644 --- a/tests/unit/api/routers/runs.test.ts +++ b/tests/unit/api/routers/runs.test.ts @@ -79,6 +79,12 @@ vi.mock('../../../../src/queue/cancel.js', () => ({ publishCancelCommand: (...args: unknown[]) => mockPublishCancelCommand(...args), })); +// Mock isAgentEnabledForProject — default: agent is enabled +const mockIsAgentEnabledForProject = vi.fn().mockResolvedValue(true); +vi.mock('../../../../src/db/repositories/agentConfigsRepository.js', () => ({ + isAgentEnabledForProject: (...args: unknown[]) => mockIsAgentEnabledForProject(...args), +})); + import { runsRouter } from '../../../../src/api/routers/runs.js'; function createCaller(ctx: TRPCContext) { @@ -851,6 +857,20 @@ describe('runsRouter', () => { ).rejects.toMatchObject({ code: 'NOT_FOUND' }); }); + it('throws BAD_REQUEST when agent is not enabled for the project', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockLoadProjectConfigById.mockResolvedValue({ + project: { id: 'p1', name: 'Test Project' }, + config: {}, + }); + mockIsAgentEnabledForProject.mockResolvedValueOnce(false); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.trigger({ projectId: 'p1', agentType: 'implementation' }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + }); + it('throws UNAUTHORIZED when unauthenticated', async () => { const caller = createCaller({ user: null, effectiveOrgId: null }); await expect( diff --git a/tests/unit/triggers/config-resolver.test.ts b/tests/unit/triggers/config-resolver.test.ts index f0a3ec94..3965c4e5 100644 --- a/tests/unit/triggers/config-resolver.test.ts +++ b/tests/unit/triggers/config-resolver.test.ts @@ -1,12 +1,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; // Hoist mocks before any imports -const { mockResolveAgentDefinition, mockGetTriggerConfig, mockGetTriggerConfigsByProjectAndAgent } = - vi.hoisted(() => ({ - mockResolveAgentDefinition: vi.fn(), - mockGetTriggerConfig: vi.fn(), - mockGetTriggerConfigsByProjectAndAgent: vi.fn(), - })); +const { + mockResolveAgentDefinition, + mockGetTriggerConfig, + mockGetTriggerConfigsByProjectAndAgent, + mockIsAgentEnabledForProject, +} = vi.hoisted(() => ({ + mockResolveAgentDefinition: vi.fn(), + mockGetTriggerConfig: vi.fn(), + mockGetTriggerConfigsByProjectAndAgent: vi.fn(), + // Default: agent is enabled (has a config row) + mockIsAgentEnabledForProject: vi.fn().mockResolvedValue(true), +})); vi.mock('../../../src/agents/definitions/index.js', () => ({ resolveAgentDefinition: mockResolveAgentDefinition, @@ -17,6 +23,10 @@ vi.mock('../../../src/db/repositories/agentTriggerConfigsRepository.js', () => ( getTriggerConfigsByProjectAndAgent: mockGetTriggerConfigsByProjectAndAgent, })); +vi.mock('../../../src/db/repositories/agentConfigsRepository.js', () => ({ + isAgentEnabledForProject: mockIsAgentEnabledForProject, +})); + import { getResolvedTriggerConfig, getTriggerParameters, @@ -68,6 +78,15 @@ function makeDbConfig(overrides: Record = {}) { describe('resolveTriggerConfigs', () => { beforeEach(() => { vi.resetAllMocks(); + // Default: agent is enabled (has a config row) + mockIsAgentEnabledForProject.mockResolvedValue(true); + }); + + it('returns empty array when agent is not enabled for project (no config row)', async () => { + mockIsAgentEnabledForProject.mockResolvedValue(false); + const result = await resolveTriggerConfigs(PROJECT_ID, AGENT_TYPE); + expect(result).toEqual([]); + expect(mockResolveAgentDefinition).not.toHaveBeenCalled(); }); it('returns empty array when agent definition is not found', async () => { @@ -143,6 +162,19 @@ describe('resolveTriggerConfigs', () => { describe('isTriggerEnabled', () => { beforeEach(() => { vi.resetAllMocks(); + // Default: agent is enabled (has a config row) + mockIsAgentEnabledForProject.mockResolvedValue(true); + }); + + it('returns false when agent has no config row (not enabled for project)', async () => { + mockIsAgentEnabledForProject.mockResolvedValue(false); + + const result = await isTriggerEnabled(PROJECT_ID, AGENT_TYPE, TRIGGER_EVENT); + + expect(result).toBe(false); + // Should not check DB trigger config or definition + expect(mockGetTriggerConfig).not.toHaveBeenCalled(); + expect(mockResolveAgentDefinition).not.toHaveBeenCalled(); }); it('returns DB override enabled value when config exists', async () => { @@ -195,6 +227,17 @@ describe('isTriggerEnabled', () => { describe('getTriggerParameters', () => { beforeEach(() => { vi.resetAllMocks(); + // Default: agent is enabled (has a config row) + mockIsAgentEnabledForProject.mockResolvedValue(true); + }); + + it('returns empty object when agent is not enabled for project (no config row)', async () => { + mockIsAgentEnabledForProject.mockResolvedValue(false); + + const result = await getTriggerParameters(PROJECT_ID, AGENT_TYPE, TRIGGER_EVENT); + + expect(result).toEqual({}); + expect(mockResolveAgentDefinition).not.toHaveBeenCalled(); }); it('returns empty object when agent definition not found', async () => { @@ -261,6 +304,17 @@ describe('getTriggerParameters', () => { describe('getResolvedTriggerConfig', () => { beforeEach(() => { vi.resetAllMocks(); + // Default: agent is enabled (has a config row) + mockIsAgentEnabledForProject.mockResolvedValue(true); + }); + + it('returns null when agent is not enabled for project (no config row)', async () => { + mockIsAgentEnabledForProject.mockResolvedValue(false); + + const result = await getResolvedTriggerConfig(PROJECT_ID, AGENT_TYPE, TRIGGER_EVENT); + + expect(result).toBeNull(); + expect(mockResolveAgentDefinition).not.toHaveBeenCalled(); }); it('returns null when agent definition not found', async () => { diff --git a/tests/unit/triggers/manual-runner.test.ts b/tests/unit/triggers/manual-runner.test.ts index 89dac37b..2fd37e21 100644 --- a/tests/unit/triggers/manual-runner.test.ts +++ b/tests/unit/triggers/manual-runner.test.ts @@ -8,6 +8,11 @@ vi.mock('../../../src/db/repositories/runsRepository.js', () => ({ getRunById: vi.fn(), })); +// Default: agent is enabled (has a config row) +vi.mock('../../../src/db/repositories/agentConfigsRepository.js', () => ({ + isAgentEnabledForProject: vi.fn().mockResolvedValue(true), +})); + vi.mock('../../../src/utils/logging.js', () => ({ logger: { info: vi.fn(), @@ -40,6 +45,7 @@ vi.mock('../../../src/triggers/shared/integration-validation.js', () => ({ })); import { runAgent } from '../../../src/agents/registry.js'; +import { isAgentEnabledForProject } from '../../../src/db/repositories/agentConfigsRepository.js'; import { getRunById } from '../../../src/db/repositories/runsRepository.js'; import { withPMCredentials } from '../../../src/pm/context.js'; import { createPMProvider, withPMProvider } from '../../../src/pm/index.js'; @@ -71,6 +77,22 @@ describe('triggerManualRun', () => { clearTriggerTracking(); }); + it('throws when agent is not enabled for the project', async () => { + vi.mocked(isAgentEnabledForProject).mockResolvedValueOnce(false); + + await expect( + triggerManualRun( + { + projectId: 'test-project', + agentType: 'implementation', + workItemId: 'card-1', + }, + mockProject, + mockConfig, + ), + ).rejects.toThrow('not enabled for project'); + }); + it('throws when trigger is already running for same project+agent+card', async () => { vi.mocked(runAgent).mockImplementation(() => new Promise(() => {})); // Never resolves diff --git a/web/src/components/projects/project-agent-configs.tsx b/web/src/components/projects/project-agent-configs.tsx index 8a964a4f..4ba69e4b 100644 --- a/web/src/components/projects/project-agent-configs.tsx +++ b/web/src/components/projects/project-agent-configs.tsx @@ -589,78 +589,109 @@ function AgentRow({ } interface AgentListViewProps { - agentTypes: string[]; + enabledAgentTypes: string[]; + availableAgentTypes: string[]; configByAgent: Map; triggersByAgent: Map; integrations: { pm: string | null; scm: string | null }; onSelect: (agentType: string) => void; onDelete: (id: number) => void; + onEnable: (agentType: string) => void; isDeleting: boolean; + isEnabling: boolean; projectModel: string | null; projectEngine: string | null; systemDefaults: SystemDefaults | undefined; } function AgentListView({ - agentTypes, + enabledAgentTypes, + availableAgentTypes, configByAgent, triggersByAgent, integrations, onSelect, onDelete, + onEnable, isDeleting, + isEnabling, projectModel, projectEngine, systemDefaults, }: AgentListViewProps) { const [deleteTarget, setDeleteTarget] = useState<{ id: number; label: string } | null>(null); - if (agentTypes.length === 0) { - return ( -
No agent definitions found.
- ); - } - return ( <> -
- - - - Agent - Status - Model / Engine - Active Triggers - - - - - {agentTypes.map((type) => ( - setDeleteTarget({ id, label })} - projectModel={projectModel} - projectEngine={projectEngine} - systemDefaults={systemDefaults} - /> - ))} - -
-
+ {enabledAgentTypes.length === 0 ? ( +
+ No agents enabled. Enable agents below to start processing. +
+ ) : ( +
+ + + + Agent + Status + Model / Engine + Active Triggers + + + + + {enabledAgentTypes.map((type) => ( + setDeleteTarget({ id, label })} + projectModel={projectModel} + projectEngine={projectEngine} + systemDefaults={systemDefaults} + /> + ))} + +
+
+ )} + + {availableAgentTypes.length > 0 && ( +
+

Available Agents

+
+ {availableAgentTypes.map((agentType) => { + const label = + (AGENT_LABELS as Record)[agentType] ?? agentType; + return ( +
+ {label} + +
+ ); + })} +
+
+ )} !open && setDeleteTarget(null)}> Delete Agent Config - Are you sure you want to delete the custom config for{' '} - {deleteTarget?.label}? The agent will revert to default settings. - This action cannot be undone. + Are you sure you want to delete the config for {deleteTarget?.label}? + The agent will be disabled and no longer process any events. This action cannot be + undone. @@ -883,12 +914,39 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { const deleteMutation = useMutation({ mutationFn: (id: number) => trpcClient.agentConfigs.delete.mutate({ id }), - onSuccess: () => queryClient.invalidateQueries({ queryKey: configsQueryKey }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: configsQueryKey }); + queryClient.invalidateQueries({ queryKey: triggersViewQueryKey }); + }, onError: (err) => { toast.error('Failed to delete agent config', { description: err.message }); }, }); + // Enable an agent by creating a bare agent_configs row (no overrides = project defaults) + const enableAgentMutation = useMutation({ + mutationFn: (agentType: string) => + trpcClient.agentConfigs.create.mutate({ + projectId, + agentType, + model: null, + maxIterations: null, + agentEngine: null, + engineSettings: null, + maxConcurrency: null, + systemPrompt: null, + taskPrompt: null, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: configsQueryKey }); + queryClient.invalidateQueries({ queryKey: triggersViewQueryKey }); + toast.success('Agent enabled'); + }, + onError: (err) => { + toast.error('Failed to enable agent', { description: err.message }); + }, + }); + // New trigger mutation (uses agentTriggerConfigs.upsert) const upsertTriggerMutation = useMutation({ mutationFn: (input: { @@ -942,18 +1000,22 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { configByAgent.set(c.agentType, c); } - // Build triggers map from API + // Build triggers map from API — use enabledAgents (configured agents only) const triggersByAgent = new Map(); const triggersViewIntegrations = triggersViewQuery.data?.integrations ?? { pm: null, scm: null, }; if (triggersViewQuery.data) { - for (const agent of triggersViewQuery.data.agents) { + const agentsList = triggersViewQuery.data.enabledAgents ?? triggersViewQuery.data.agents ?? []; + for (const agent of agentsList) { triggersByAgent.set(agent.agentType, agent.triggers as ResolvedTrigger[]); } } + // Available (unconfigured) agent types + const availableAgentTypes = triggersViewQuery.data?.availableAgents ?? []; + const handleSaveConfig = (type: string, configId: number | null, values: SaveConfigValues) => { setSavingAgentType(type); const activeEngine = values.agentEngine || null; @@ -1013,8 +1075,8 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { ); }; - // Get list of agent types to display - const agentTypes = Array.from(triggersByAgent.keys()); + // Get list of enabled agent types to display + const enabledAgentTypes = Array.from(triggersByAgent.keys()); // Render detail view when an agent is selected if (selectedAgent !== null) { @@ -1053,13 +1115,16 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) {

deleteMutation.mutate(id)} + onEnable={(agentType) => enableAgentMutation.mutate(agentType)} isDeleting={deleteMutation.isPending} + isEnabling={enableAgentMutation.isPending} projectModel={projectModel} projectEngine={projectEngine} systemDefaults={systemDefaults} From a43910cfea653aeb8262ecd75f24791c8b98a682 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Mon, 16 Mar 2026 13:44:39 +0000 Subject: [PATCH 2/5] fix(tests): update integration tests for agent opt-in enforcement The new `isAgentEnabledForProject()` guard requires an explicit `agent_configs` row before any trigger can fire. Integration tests that called `handle()` and expected a non-null result were failing because no agent config was seeded. - Seed `agent_configs` + `agent_trigger_configs` rows in tests that expect triggers to fire (trigger-registry, pm-provider-switching, github-personas) - Export `clearAgentEnabledCache()` from agentConfigsRepository for test isolation (the 5s TTL cache was causing false negatives when tests ran within the TTL window of a prior test that had no config) - Call `clearAgentEnabledCache()` in `truncateAll()` so every `beforeEach` starts with a clean cache alongside a clean DB Co-Authored-By: Claude Opus 4.6 --- src/db/repositories/agentConfigsRepository.ts | 8 ++++ tests/integration/github-personas.test.ts | 17 ++++++- tests/integration/helpers/db.ts | 4 ++ .../integration/pm-provider-switching.test.ts | 29 +++++++++++- tests/integration/trigger-registry.test.ts | 46 ++++++++++++++++++- 5 files changed, 101 insertions(+), 3 deletions(-) diff --git a/src/db/repositories/agentConfigsRepository.ts b/src/db/repositories/agentConfigsRepository.ts index 54ee95ee..eaaf7bf7 100644 --- a/src/db/repositories/agentConfigsRepository.ts +++ b/src/db/repositories/agentConfigsRepository.ts @@ -158,6 +158,14 @@ export async function isAgentEnabledForProject( return result; } +/** + * Clear the agent-enabled cache (for testing only). + * This allows integration tests to seed agent configs and see them without waiting for TTL expiry. + */ +export function clearAgentEnabledCache(): void { + agentEnabledCache.clear(); +} + /** * Resolve max_concurrency for a (projectId, agentType) pair. * Returns null if no project-scoped config with max_concurrency is found (= no limit). diff --git a/tests/integration/github-personas.test.ts b/tests/integration/github-personas.test.ts index 19fe6451..c4cf9639 100644 --- a/tests/integration/github-personas.test.ts +++ b/tests/integration/github-personas.test.ts @@ -22,7 +22,13 @@ import { ReviewRequestedTrigger } from '../../src/triggers/github/review-request import type { TriggerContext } from '../../src/types/index.js'; import { assertFound } from './helpers/assert.js'; import { truncateAll } from './helpers/db.js'; -import { seedIntegration, seedOrg, seedProject, seedTriggerConfig } from './helpers/seed.js'; +import { + seedAgentConfig, + seedIntegration, + seedOrg, + seedProject, + seedTriggerConfig, +} from './helpers/seed.js'; // ============================================================================ // Helpers @@ -229,6 +235,13 @@ describe('GitHub Dual-Persona System (integration)', () => { config: {}, triggers: { prReviewSubmitted: true }, }); + // Agent must be explicitly enabled for the trigger to fire + await seedAgentConfig({ agentType: 'respond-to-review' }); + await seedTriggerConfig({ + agentType: 'respond-to-review', + triggerEvent: 'scm:pr-review-submitted', + enabled: true, + }); const project = await findProjectByRepoFromDb('owner/repo'); expect(project).toBeDefined(); @@ -350,6 +363,8 @@ describe('GitHub Dual-Persona System (integration)', () => { provider: 'github', config: {}, }); + // Agent must be explicitly enabled for the trigger to fire + await seedAgentConfig({ agentType: 'review' }); await seedTriggerConfig({ agentType: 'review', triggerEvent: 'scm:review-requested', diff --git a/tests/integration/helpers/db.ts b/tests/integration/helpers/db.ts index df3720ad..f1c605e1 100644 --- a/tests/integration/helpers/db.ts +++ b/tests/integration/helpers/db.ts @@ -4,6 +4,7 @@ import net from 'node:net'; import path from 'node:path'; import { migrate } from 'drizzle-orm/node-postgres/migrator'; import { _setTestDb, closeDb, getDb } from '../../../src/db/client.js'; +import { clearAgentEnabledCache } from '../../../src/db/repositories/agentConfigsRepository.js'; function checkPortReachable(host: string, port: number, timeoutMs = 500): Promise { return new Promise((resolve) => { @@ -98,6 +99,7 @@ export async function runMigrations() { /** * Truncates all application tables in dependency order. * Call in `beforeEach` to isolate tests. + * Also clears in-memory repository caches so tests see fresh DB state. */ export async function truncateAll() { const db = getDb(); @@ -121,6 +123,8 @@ export async function truncateAll() { organizations CASCADE `); + // Clear in-memory caches so subsequent tests see fresh DB state + clearAgentEnabledCache(); } /** diff --git a/tests/integration/pm-provider-switching.test.ts b/tests/integration/pm-provider-switching.test.ts index c866a576..3d8f8fcf 100644 --- a/tests/integration/pm-provider-switching.test.ts +++ b/tests/integration/pm-provider-switching.test.ts @@ -21,7 +21,13 @@ import { TrelloStatusChangedTodoTrigger } from '../../src/triggers/trello/status import type { TriggerContext } from '../../src/types/index.js'; import { assertFound } from './helpers/assert.js'; import { truncateAll } from './helpers/db.js'; -import { seedIntegration, seedOrg, seedProject } from './helpers/seed.js'; +import { + seedAgentConfig, + seedIntegration, + seedOrg, + seedProject, + seedTriggerConfig, +} from './helpers/seed.js'; // ============================================================================ // Helpers @@ -226,6 +232,13 @@ describe('PM Provider Switching (integration)', () => { labels: {}, }, }); + // Agent must be explicitly enabled for the trigger to fire + await seedAgentConfig({ agentType: 'implementation' }); + await seedTriggerConfig({ + agentType: 'implementation', + triggerEvent: 'pm:status-changed', + enabled: true, + }); const project = await findProjectByBoardIdFromDb('board-123'); expect(project).toBeDefined(); @@ -252,6 +265,13 @@ describe('PM Provider Switching (integration)', () => { statuses: { todo: 'To Do', planning: 'In Planning', splitting: 'Splitting' }, }, }); + // Agent must be explicitly enabled for the trigger to fire + await seedAgentConfig({ agentType: 'implementation' }); + await seedTriggerConfig({ + agentType: 'implementation', + triggerEvent: 'pm:status-changed', + enabled: true, + }); const project = await findProjectByJiraProjectKeyFromDb('IMPL'); expect(project).toBeDefined(); @@ -279,6 +299,13 @@ describe('PM Provider Switching (integration)', () => { statuses: { todo: 'To Do', planning: 'In Planning', splitting: 'Splitting' }, }, }); + // Agent must be explicitly enabled for the trigger to fire + await seedAgentConfig({ agentType: 'planning' }); + await seedTriggerConfig({ + agentType: 'planning', + triggerEvent: 'pm:status-changed', + enabled: true, + }); const project = await findProjectByJiraProjectKeyFromDb('PLAN'); expect(project).toBeDefined(); diff --git a/tests/integration/trigger-registry.test.ts b/tests/integration/trigger-registry.test.ts index 5e3405fb..09bc2a56 100644 --- a/tests/integration/trigger-registry.test.ts +++ b/tests/integration/trigger-registry.test.ts @@ -20,7 +20,13 @@ import { import type { TriggerContext } from '../../src/types/index.js'; import { assertFound } from './helpers/assert.js'; import { truncateAll } from './helpers/db.js'; -import { seedIntegration, seedOrg, seedProject, seedTriggerConfig } from './helpers/seed.js'; +import { + seedAgentConfig, + seedIntegration, + seedOrg, + seedProject, + seedTriggerConfig, +} from './helpers/seed.js'; // ============================================================================ // Helpers @@ -291,6 +297,14 @@ describe('Trigger Registry (integration)', () => { labels: {}, }, }); + // Agent must be explicitly enabled for the trigger to fire + await seedAgentConfig({ agentType: 'implementation' }); + // Seed trigger config to enable the trigger + await seedTriggerConfig({ + agentType: 'implementation', + triggerEvent: 'pm:status-changed', + enabled: true, + }); const project = await findProjectByBoardIdFromDb('board-123'); @@ -323,6 +337,14 @@ describe('Trigger Registry (integration)', () => { labels: {}, }, }); + // Agent must be explicitly enabled for the trigger to fire + await seedAgentConfig({ agentType: 'splitting' }); + // Seed trigger config to enable the trigger + await seedTriggerConfig({ + agentType: 'splitting', + triggerEvent: 'pm:status-changed', + enabled: true, + }); const project = await findProjectByBoardIdFromDb('board-123'); @@ -353,6 +375,14 @@ describe('Trigger Registry (integration)', () => { labels: {}, }, }); + // Agent must be explicitly enabled for the trigger to fire + await seedAgentConfig({ agentType: 'planning' }); + // Seed trigger config to enable the trigger + await seedTriggerConfig({ + agentType: 'planning', + triggerEvent: 'pm:status-changed', + enabled: true, + }); const project = await findProjectByBoardIdFromDb('board-123'); @@ -525,6 +555,20 @@ describe('Trigger Registry (integration)', () => { labels: { readyToProcess: 'Ready to Process' }, }, }); + // Agents must be explicitly enabled for triggers to fire + await seedAgentConfig({ agentType: 'implementation' }); + await seedAgentConfig({ agentType: 'splitting' }); + // Seed trigger configs to enable the triggers + await seedTriggerConfig({ + agentType: 'implementation', + triggerEvent: 'pm:status-changed', + enabled: true, + }); + await seedTriggerConfig({ + agentType: 'splitting', + triggerEvent: 'pm:status-changed', + enabled: true, + }); const registry = createTriggerRegistry(); registry.register(TrelloStatusChangedSplittingTrigger); From f273865194c1d2668a7680ccce89a45ccbe72192 Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Mon, 16 Mar 2026 16:23:36 +0000 Subject: [PATCH 3/5] feat(agents): expose YAML default task prompt in getPrompts API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `getDefaultTaskPrompt(agentType)` to `src/agents/prompts/index.ts` Reads the factory-default task prompt directly from the YAML definition without requiring `initPrompts()`. Returns null for unknown agent types. - Wire it into the `agentConfigs.getPrompts` tRPC endpoint as a fourth prompt layer (`defaultTaskPrompt`), completing the inheritance chain: project override → global override → default system (disk template) → default task (YAML definition). - Update `agent-prompt-overrides.tsx` to use `defaultTaskPrompt` as the final fallback when initialising the task prompt editor and as the target of the "Load default" button. - Add `startWatchdog(project.watchdogTimeoutMs)` to `triggerManualRun` so manual runs respect the per-project timeout the same way webhook- triggered runs do. - Fix unit tests: add `getDefaultTaskPrompt` to the `prompts/index.js` mock in agentConfigs.test.ts; mock `lifecycle.js` in manual-runner.test.ts to prevent the watchdog timer from calling process.exit during tests. Co-Authored-By: Claude Sonnet 4.6 --- src/agents/prompts/index.ts | 15 +++++++++++++++ src/api/routers/agentConfigs.ts | 10 +++++++++- src/triggers/shared/manual-runner.ts | 3 +++ tests/unit/api/routers/agentConfigs.test.ts | 5 +++++ tests/unit/triggers/manual-runner.test.ts | 4 ++++ .../projects/agent-prompt-overrides.tsx | 11 ++++++----- 6 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/agents/prompts/index.ts b/src/agents/prompts/index.ts index 1236a244..ae0e650d 100644 --- a/src/agents/prompts/index.ts +++ b/src/agents/prompts/index.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'; import { Eta } from 'eta'; import { resolveKnownAgentTypes } from '../definitions/index.js'; +import { loadAgentDefinition } from '../definitions/loader.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const templatesDir = join(__dirname, 'templates'); @@ -231,6 +232,20 @@ export function renderInlineTaskPrompt( return taskEta.renderString(expanded, context); } +/** + * Returns the YAML-defined taskPrompt for an agent type (the factory default). + * Does not require initPrompts() — reads directly from YAML. + * Returns null if the agent type is unknown or has no taskPrompt defined. + */ +export function getDefaultTaskPrompt(agentType: string): string | null { + try { + const definition = loadAgentDefinition(agentType); + return definition.prompts.taskPrompt ?? null; + } catch { + return null; + } +} + /** Returns the raw .eta template source from disk (before rendering). */ export function getRawTemplate(agentType: string): string { requireInitialized('getRawTemplate'); diff --git a/src/api/routers/agentConfigs.ts b/src/api/routers/agentConfigs.ts index 466a1436..8d46bd01 100644 --- a/src/api/routers/agentConfigs.ts +++ b/src/api/routers/agentConfigs.ts @@ -2,7 +2,11 @@ import { TRPCError } from '@trpc/server'; import { eq } from 'drizzle-orm'; import { z } from 'zod'; import { resolveAgentDefinition } from '../../agents/definitions/index.js'; -import { getRawTemplate, validateTemplate } from '../../agents/prompts/index.js'; +import { + getDefaultTaskPrompt, + getRawTemplate, + validateTemplate, +} from '../../agents/prompts/index.js'; import { getEngineCatalog, registerBuiltInEngines } from '../../backends/index.js'; import { EngineSettingsSchema } from '../../config/engineSettings.js'; import { getDb } from '../../db/client.js'; @@ -178,12 +182,16 @@ export const agentConfigsRouter = router({ // No .eta template on disk — skip gracefully } + // 4. YAML-defined task prompt (factory default) + const defaultTaskPrompt = getDefaultTaskPrompt(input.agentType); + return { projectSystemPrompt, projectTaskPrompt, globalSystemPrompt, globalTaskPrompt, defaultSystemPrompt, + defaultTaskPrompt, }; }), }); diff --git a/src/triggers/shared/manual-runner.ts b/src/triggers/shared/manual-runner.ts index 715507a0..ba63bcca 100644 --- a/src/triggers/shared/manual-runner.ts +++ b/src/triggers/shared/manual-runner.ts @@ -4,6 +4,7 @@ import { getRunById } from '../../db/repositories/runsRepository.js'; import { withPMCredentials } from '../../pm/context.js'; import { createPMProvider, pmRegistry, withPMProvider } from '../../pm/index.js'; import type { AgentInput, CascadeConfig, ProjectConfig } from '../../types/index.js'; +import { startWatchdog } from '../../utils/lifecycle.js'; import { logger } from '../../utils/logging.js'; import { formatValidationErrors, validateIntegrations } from './integration-validation.js'; @@ -104,6 +105,8 @@ export async function triggerManualRun( markTriggerRunning(triggerKey); + startWatchdog(project.watchdogTimeoutMs); + const agentInput: AgentInput & { project: ProjectConfig; config: CascadeConfig } = { workItemId: input.workItemId, prNumber: input.prNumber, diff --git a/tests/unit/api/routers/agentConfigs.test.ts b/tests/unit/api/routers/agentConfigs.test.ts index 1fadeaa5..61e20b27 100644 --- a/tests/unit/api/routers/agentConfigs.test.ts +++ b/tests/unit/api/routers/agentConfigs.test.ts @@ -15,6 +15,7 @@ const { mockLoadPartials, mockResolveAgentDefinition, mockGetRawTemplate, + mockGetDefaultTaskPrompt, } = vi.hoisted(() => ({ mockListAgentConfigs: vi.fn(), mockCreateAgentConfig: vi.fn(), @@ -27,6 +28,7 @@ const { mockLoadPartials: vi.fn(), mockResolveAgentDefinition: vi.fn(), mockGetRawTemplate: vi.fn(), + mockGetDefaultTaskPrompt: vi.fn().mockReturnValue(null), })); vi.mock('../../../../src/db/repositories/settingsRepository.js', () => ({ @@ -45,6 +47,7 @@ vi.mock('../../../../src/backends/index.js', () => ({ vi.mock('../../../../src/agents/prompts/index.js', () => ({ validateTemplate: (...args: unknown[]) => mockValidateTemplate(...args), getRawTemplate: (...args: unknown[]) => mockGetRawTemplate(...args), + getDefaultTaskPrompt: (...args: unknown[]) => mockGetDefaultTaskPrompt(...args), })); vi.mock('../../../../src/db/repositories/partialsRepository.js', () => ({ @@ -566,6 +569,7 @@ describe('agentConfigsRouter', () => { }, }); mockGetRawTemplate.mockReturnValue('raw disk template content'); + mockGetDefaultTaskPrompt.mockReturnValue('yaml default task prompt'); const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); const result = await caller.getPrompts({ projectId: 'proj-1', agentType: 'implementation' }); @@ -576,6 +580,7 @@ describe('agentConfigsRouter', () => { globalSystemPrompt: 'global system prompt', globalTaskPrompt: 'global task prompt', defaultSystemPrompt: 'raw disk template content', + defaultTaskPrompt: 'yaml default task prompt', }); }); diff --git a/tests/unit/triggers/manual-runner.test.ts b/tests/unit/triggers/manual-runner.test.ts index 2fd37e21..f86808b1 100644 --- a/tests/unit/triggers/manual-runner.test.ts +++ b/tests/unit/triggers/manual-runner.test.ts @@ -44,6 +44,10 @@ vi.mock('../../../src/triggers/shared/integration-validation.js', () => ({ formatValidationErrors: vi.fn().mockReturnValue(''), })); +vi.mock('../../../src/utils/lifecycle.js', () => ({ + startWatchdog: vi.fn(), +})); + import { runAgent } from '../../../src/agents/registry.js'; import { isAgentEnabledForProject } from '../../../src/db/repositories/agentConfigsRepository.js'; import { getRunById } from '../../../src/db/repositories/runsRepository.js'; diff --git a/web/src/components/projects/agent-prompt-overrides.tsx b/web/src/components/projects/agent-prompt-overrides.tsx index c01dd003..94770753 100644 --- a/web/src/components/projects/agent-prompt-overrides.tsx +++ b/web/src/components/projects/agent-prompt-overrides.tsx @@ -66,7 +66,8 @@ export function AgentPromptOverrides({ // Initialize with project override, then fall back to global, then default const initialSystem = data.projectSystemPrompt ?? data.globalSystemPrompt ?? data.defaultSystemPrompt ?? ''; - const initialTask = data.projectTaskPrompt ?? data.globalTaskPrompt ?? ''; + const initialTask = + data.projectTaskPrompt ?? data.globalTaskPrompt ?? data.defaultTaskPrompt ?? ''; onSystemPromptChange(initialSystem); onTaskPromptChange(initialTask); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -123,7 +124,7 @@ export function AgentPromptOverrides({ const taskBadge = getInheritanceBadge({ projectOverride: data?.projectTaskPrompt ?? null, globalPrompt: data?.globalTaskPrompt ?? null, - defaultPrompt: null, + defaultPrompt: data?.defaultTaskPrompt ?? null, }); const currentBadge = isSystemSection ? systemBadge : taskBadge; @@ -138,8 +139,8 @@ export function AgentPromptOverrides({ if (isSystemSection && data?.defaultSystemPrompt) { onSystemPromptChange(data.defaultSystemPrompt); setValidationStatus(null); - } else if (!isSystemSection && data?.globalTaskPrompt) { - onTaskPromptChange(data.globalTaskPrompt); + } else if (!isSystemSection && data?.defaultTaskPrompt) { + onTaskPromptChange(data.defaultTaskPrompt); setValidationStatus(null); } }; @@ -163,7 +164,7 @@ export function AgentPromptOverrides({ const hasProjectSystemOverride = !!data?.projectSystemPrompt; const hasProjectTaskOverride = !!data?.projectTaskPrompt; - const canLoadDefault = isSystemSection ? !!data?.defaultSystemPrompt : !!data?.globalTaskPrompt; + const canLoadDefault = isSystemSection ? !!data?.defaultSystemPrompt : !!data?.defaultTaskPrompt; return (
From 2c4b078641ec40900b1a620a3679e58a4b5c5ca4 Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Mon, 16 Mar 2026 16:24:08 +0000 Subject: [PATCH 4/5] feat(router): unified agent run timeout system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces two independent, uncoordinated timeout mechanisms with a single coherent flow where `watchdogTimeoutMs` is the source of truth. ## Problem Two timeouts existed with no knowledge of each other: 1. In-container watchdog (`startWatchdog(project.watchdogTimeoutMs)`) — per-project, updates DB to `timed_out` then exits. 2. Router-level kill (`setTimeout → killWorker`) — global env var, killed the Docker container with no DB update. This caused three bugs: - If `WORKER_TIMEOUT_MS` < `watchdogTimeoutMs` the router killed the container before the watchdog could set the correct DB status. - GitHub-triggered runs (no `workItemId`) were never marked in the DB after a router kill — they stayed `running` forever. - Orphaned containers (after router restart) were stopped but their DB runs were never updated. ## Solution **Per-project timeout in `spawnWorker`**: router now reads `watchdogTimeoutMs` from project config and uses it + 2-minute buffer (`ROUTER_KILL_BUFFER_MS`) for the container kill timer, so the watchdog always fires first and the router is purely a backstop. **DB update on router kill (`killWorker`)**: after stopping the container, marks the run `timed_out` via `failOrphanedRun` (workItemId path) or `failOrphanedRunFallback` (GitHub PR runs without workItemId). The call to `cleanupWorker` no longer passes an exit code so it skips its own DB write, eliminating the race that could set the wrong status (`failed` instead of `timed_out`). **Fallback for GitHub PR runs (`failOrphanedRunFallback`)**: new repository function that finds the most recent running run by `projectId + agentType + startedAt ≥ containerStart` and marks it, guarded by an optimistic `WHERE status='running'` check so it is always safe to call even if the watchdog already acted. **DB update in `cleanupWorker`**: extended to also handle the workItemId-absent case via `failOrphanedRunFallback`, covering crashes of GitHub PR runs that the watchdog didn't catch. **`cascade.agent.type` container label**: added at spawn time so orphan cleanup can pass `agentType` to `failOrphanedRunFallback`, avoiding matching the wrong run when multiple agent types run concurrently. **`durationMs` on orphaned runs**: all three fail paths now compute and persist the elapsed duration so dashboard users see actual run time instead of null. **Fixed BullMQ `lockDuration`**: replaced `workerTimeoutMs + 60s` with a fixed 8-hour constant (`BULLMQ_LOCK_DURATION_MS`) — `guardedSpawn` resolves immediately after container start so the lock is held for seconds, and tying it to `workerTimeoutMs` risked lock expiry for long-running project configs. Co-Authored-By: Claude Sonnet 4.6 --- src/db/repositories/runsRepository.ts | 48 +++++- src/router/active-workers.ts | 57 ++++--- src/router/container-manager.ts | 71 ++++++++- src/router/orphan-cleanup.ts | 32 ++++ src/router/worker-manager.ts | 9 +- tests/unit/router/active-workers.test.ts | 63 ++++++++ tests/unit/router/container-manager.test.ts | 137 +++++++++++++++- tests/unit/router/orphan-cleanup.test.ts | 163 +++++++++++++++++++- 8 files changed, 537 insertions(+), 43 deletions(-) diff --git a/src/db/repositories/runsRepository.ts b/src/db/repositories/runsRepository.ts index 78a0d531..1fbac5ff 100644 --- a/src/db/repositories/runsRepository.ts +++ b/src/db/repositories/runsRepository.ts @@ -358,6 +358,8 @@ export async function failOrphanedRun( projectId: string, workItemId: string, reason: string, + status: 'failed' | 'timed_out' = 'failed', + durationMs?: number, ): Promise { const db = getDb(); const [row] = await db @@ -377,9 +379,53 @@ export async function failOrphanedRun( const [updated] = await db .update(agentRuns) .set({ - status: 'failed', + status, + completedAt: new Date(), + error: reason, + durationMs, + }) + .where(and(eq(agentRuns.id, row.id), eq(agentRuns.status, 'running'))) + .returning({ id: agentRuns.id }); + return updated?.id ?? null; +} + +/** + * Fail the most recent running run for a project without a workItemId (e.g. GitHub PR runs). + * Uses projectId + optional agentType + startedAfter to identify the run. + * Guards on status='running' so it's safe to call even if the run already completed. + */ +export async function failOrphanedRunFallback( + projectId: string, + agentType: string | undefined, + startedAfter: Date, + status: 'failed' | 'timed_out', + reason: string, + durationMs?: number, +): Promise { + const db = getDb(); + const conditions: SQL[] = [ + eq(agentRuns.projectId, projectId), + eq(agentRuns.status, 'running'), + gte(agentRuns.startedAt, startedAfter), + ]; + if (agentType) { + conditions.push(eq(agentRuns.agentType, agentType)); + } + const [row] = await db + .select({ id: agentRuns.id }) + .from(agentRuns) + .where(and(...conditions)) + .orderBy(desc(agentRuns.startedAt)) + .limit(1); + if (!row) return null; + + const [updated] = await db + .update(agentRuns) + .set({ + status, completedAt: new Date(), error: reason, + durationMs, }) .where(and(eq(agentRuns.id, row.id), eq(agentRuns.status, 'running'))) .returning({ id: agentRuns.id }); diff --git a/src/router/active-workers.ts b/src/router/active-workers.ts index f55b74c6..8e3b74eb 100644 --- a/src/router/active-workers.ts +++ b/src/router/active-workers.ts @@ -5,7 +5,7 @@ * Tracks running worker containers and handles cleanup of their associated locks. */ -import { failOrphanedRun } from '../db/repositories/runsRepository.js'; +import { failOrphanedRun, failOrphanedRunFallback } from '../db/repositories/runsRepository.js'; import { logger } from '../utils/logging.js'; import { clearAgentTypeEnqueued } from './agent-type-lock.js'; import type { CascadeJob } from './queue.js'; @@ -46,7 +46,9 @@ export function getActiveWorkers(): Array<{ jobId: string; startedAt: Date }> { /** * Clean up worker tracking state (timeout handle + map entry). - * When exitCode is non-zero, marks the corresponding DB run as failed (fire-and-forget). + * When exitCode is non-zero, marks the DB run as 'failed' — crash path only. + * The timeout path (killWorker) handles its own 'timed_out' DB update and calls + * cleanupWorker without an exitCode so this block is skipped. */ export function cleanupWorker(jobId: string, exitCode?: number): void { const worker = activeWorkers.get(jobId); @@ -58,29 +60,40 @@ export function cleanupWorker(jobId: string, exitCode?: number): void { if (worker.projectId && worker.workItemId && worker.agentType) { clearWorkItemEnqueued(worker.projectId, worker.workItemId, worker.agentType); } - if (worker.projectId && worker.workItemId) { - if (exitCode !== undefined && exitCode !== 0) { - failOrphanedRun( - worker.projectId, - worker.workItemId, - `Worker crashed with exit code ${exitCode}`, - ) - .then((runId) => { - if (runId) { - logger.info('[WorkerManager] Marked orphaned run as failed:', { - jobId, - runId, - exitCode, - }); - } - }) - .catch((err) => { - logger.error('[WorkerManager] Failed to mark orphaned run:', { + if (exitCode !== undefined && exitCode !== 0 && worker.projectId) { + const durationMs = Date.now() - worker.startedAt.getTime(); + const updatePromise = worker.workItemId + ? failOrphanedRun( + worker.projectId, + worker.workItemId, + `Worker crashed with exit code ${exitCode}`, + 'failed', + durationMs, + ) + : failOrphanedRunFallback( + worker.projectId, + worker.agentType, + worker.startedAt, + 'failed', + `Worker crashed with exit code ${exitCode}`, + durationMs, + ); + updatePromise + .then((runId) => { + if (runId) { + logger.info('[WorkerManager] Marked orphaned run as failed:', { jobId, - error: String(err), + runId, + exitCode, }); + } + }) + .catch((err) => { + logger.error('[WorkerManager] Failed to mark orphaned run:', { + jobId, + error: String(err), }); - } + }); } activeWorkers.delete(jobId); logger.info('[WorkerManager] Worker cleaned up:', { diff --git a/src/router/container-manager.ts b/src/router/container-manager.ts index ac9d96e1..4eb92ac7 100644 --- a/src/router/container-manager.ts +++ b/src/router/container-manager.ts @@ -12,11 +12,12 @@ import type { Job } from 'bullmq'; import Docker from 'dockerode'; +import { failOrphanedRun, failOrphanedRunFallback } from '../db/repositories/runsRepository.js'; import { captureException } from '../sentry.js'; import { logger } from '../utils/logging.js'; import { activeWorkers, cleanupWorker } from './active-workers.js'; import { clearAllAgentTypeLocks } from './agent-type-lock.js'; -import { routerConfig } from './config.js'; +import { loadProjectConfig, routerConfig } from './config.js'; import { notifyTimeout } from './notifications.js'; import { stopOrphanCleanup } from './orphan-cleanup.js'; import type { CascadeJob } from './queue.js'; @@ -48,6 +49,9 @@ export { const docker = new Docker(); +/** Buffer added on top of the in-container watchdog so the router kill is always a backstop. */ +const ROUTER_KILL_BUFFER_MS = 2 * 60 * 1000; + /** * Spawn a worker container for a job. * Sets up timeout tracking and monitors container exit asynchronously. @@ -61,6 +65,22 @@ export async function spawnWorker(job: Job): Promise { const workerEnv = await buildWorkerEnvWithProjectId(job, projectId); const hasCredentials = workerEnv.some((e) => e.startsWith('CASCADE_CREDENTIAL_KEYS=')); + // Extract agentType early so it can be included in container labels + // (needed by orphan cleanup to narrow DB fallback queries to the right agent type) + const agentType = extractAgentType(job.data); + + // Determine container timeout: use project's watchdogTimeoutMs + buffer if available, + // falling back to the global workerTimeoutMs. This makes watchdogTimeoutMs the single source + // of truth — the in-container watchdog fires first, router kill is a backup. + let containerTimeoutMs = routerConfig.workerTimeoutMs; + if (projectId) { + const { fullProjects } = await loadProjectConfig(); + const projectCfg = fullProjects.find((p) => p.id === projectId); + if (projectCfg?.watchdogTimeoutMs) { + containerTimeoutMs = projectCfg.watchdogTimeoutMs + ROUTER_KILL_BUFFER_MS; + } + } + logger.info('[WorkerManager] Spawning worker:', { jobId, type: job.data.type, @@ -83,12 +103,14 @@ export async function spawnWorker(job: Job): Promise { 'cascade.job.id': jobId, 'cascade.job.type': job.data.type, 'cascade.managed': 'true', + 'cascade.project.id': projectId ?? '', + 'cascade.agent.type': agentType ?? '', }, }); await container.start(); - // Set up timeout + // Set up timeout — fires at watchdogTimeoutMs + 2min (router backup kill) const startedAt = new Date(); const timeoutHandle = setTimeout(() => { const durationMs = Date.now() - startedAt.getTime(); @@ -104,11 +126,10 @@ export async function spawnWorker(job: Job): Promise { killWorker(jobId).catch((err) => { logger.error('[WorkerManager] Failed to kill timed-out worker:', err); }); - }, routerConfig.workerTimeoutMs); + }, containerTimeoutMs); // Track the worker const workItemId = extractWorkItemId(job.data); - const agentType = extractAgentType(job.data); activeWorkers.set(jobId, { containerId: container.id, jobId, @@ -203,8 +224,45 @@ export async function killWorker(jobId: string): Promise { }); } - // Send timeout notification (fire-and-forget) const durationMs = Date.now() - worker.startedAt.getTime(); + + // Update DB run status to timed_out (fire-and-forget, no-op if watchdog already did it). + // cleanupWorker is called below without an exitCode so it skips its own DB update, + // avoiding a race where the wrong status ('failed') could win. + if (worker.projectId) { + const dbUpdate = worker.workItemId + ? failOrphanedRun( + worker.projectId, + worker.workItemId, + 'Router timeout', + 'timed_out', + durationMs, + ) + : failOrphanedRunFallback( + worker.projectId, + worker.agentType, + worker.startedAt, + 'timed_out', + 'Router timeout', + durationMs, + ); + dbUpdate + .then((runId) => { + if (runId) + logger.info('[WorkerManager] Marked run timed_out after router kill', { + jobId, + runId, + }); + }) + .catch((err) => + logger.error('[WorkerManager] DB update failed after router kill', { + jobId, + error: String(err), + }), + ); + } + + // Send timeout notification (fire-and-forget) notifyTimeout(worker.job, { jobId: worker.jobId, startedAt: worker.startedAt, @@ -213,7 +271,8 @@ export async function killWorker(jobId: string): Promise { logger.error('[WorkerManager] Timeout notification error:', String(err)); }); - cleanupWorker(jobId, 137); + // No exitCode — DB update is handled above with the correct 'timed_out' status + cleanupWorker(jobId); } /** diff --git a/src/router/orphan-cleanup.ts b/src/router/orphan-cleanup.ts index cc690b36..f8747a02 100644 --- a/src/router/orphan-cleanup.ts +++ b/src/router/orphan-cleanup.ts @@ -6,6 +6,7 @@ */ import Docker from 'dockerode'; +import { failOrphanedRunFallback } from '../db/repositories/runsRepository.js'; import { captureException } from '../sentry.js'; import { logger } from '../utils/logging.js'; import { getTrackedContainerIds } from './active-workers.js'; @@ -108,6 +109,37 @@ export async function scanAndCleanupOrphans(): Promise { containerId: containerId.slice(0, 12), ageMinutes, }); + + // Update DB run status (fire-and-forget). Containers created before this + // change won't have labels (projectId = '' → falsy) → skip, harmless. + const projectId = containerInfo.Labels?.['cascade.project.id']; + if (projectId) { + const containerCreatedAt = new Date(containerInfo.Created * 1000); + const orphanDurationMs = now - containerInfo.Created * 1000; + // agentType narrows the fallback query when multiple agent types run concurrently + const orphanAgentType = containerInfo.Labels?.['cascade.agent.type'] || undefined; + failOrphanedRunFallback( + projectId, + orphanAgentType, + containerCreatedAt, + 'failed', + 'Orphan cleanup: container stopped', + orphanDurationMs, + ) + .then((runId) => { + if (runId) + logger.info('[WorkerManager] Marked orphaned run as failed after cleanup', { + containerId: containerId.slice(0, 12), + runId, + }); + }) + .catch((err) => + logger.error('[WorkerManager] DB update failed after orphan cleanup', { + containerId: containerId.slice(0, 12), + error: String(err), + }), + ); + } } catch (err) { // Container might already be stopped — log but continue logger.warn('[WorkerManager] Error stopping orphaned container:', { diff --git a/src/router/worker-manager.ts b/src/router/worker-manager.ts index 58798157..c6612f9b 100644 --- a/src/router/worker-manager.ts +++ b/src/router/worker-manager.ts @@ -28,6 +28,11 @@ export { getActiveWorkerCount, getActiveWorkers, startOrphanCleanup, stopOrphanC let bullWorker: Worker | null = null; let dashboardWorker: Worker | null = null; +// Fixed lock duration that outlasts any realistic run. guardedSpawn resolves +// immediately after container start, so BullMQ holds the lock for mere seconds. +// Using a fixed 8-hour value prevents lock expiry for long-running containers. +const BULLMQ_LOCK_DURATION_MS = 8 * 60 * 60 * 1000; + /** Guard that enforces the per-router concurrency cap before spawning. */ async function guardedSpawn(job: Job): Promise { // Check if we have capacity. @@ -55,7 +60,7 @@ export function startWorkerProcessor(): void { label: 'Job', connection, concurrency: routerConfig.maxWorkers, - lockDuration: routerConfig.workerTimeoutMs + 60000, + lockDuration: BULLMQ_LOCK_DURATION_MS, processFn: guardedSpawn, }); @@ -66,7 +71,7 @@ export function startWorkerProcessor(): void { label: 'Dashboard job', connection, concurrency: routerConfig.maxWorkers, - lockDuration: routerConfig.workerTimeoutMs + 60000, + lockDuration: BULLMQ_LOCK_DURATION_MS, processFn: (job) => guardedSpawn(job as Job), }); diff --git a/tests/unit/router/active-workers.test.ts b/tests/unit/router/active-workers.test.ts index aae8e6b8..0da4dc5e 100644 --- a/tests/unit/router/active-workers.test.ts +++ b/tests/unit/router/active-workers.test.ts @@ -6,12 +6,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const { mockFailOrphanedRun, + mockFailOrphanedRunFallback, mockClearWorkItemEnqueued, mockClearAllWorkItemLocks, mockClearAgentTypeEnqueued, mockClearAllAgentTypeLocks, } = vi.hoisted(() => ({ mockFailOrphanedRun: vi.fn().mockResolvedValue(null), + mockFailOrphanedRunFallback: vi.fn().mockResolvedValue(null), mockClearWorkItemEnqueued: vi.fn(), mockClearAllWorkItemLocks: vi.fn(), mockClearAgentTypeEnqueued: vi.fn(), @@ -24,6 +26,7 @@ const { vi.mock('../../../src/db/repositories/runsRepository.js', () => ({ failOrphanedRun: (...args: unknown[]) => mockFailOrphanedRun(...args), + failOrphanedRunFallback: (...args: unknown[]) => mockFailOrphanedRunFallback(...args), })); vi.mock('../../../src/router/work-item-lock.js', () => ({ @@ -79,6 +82,8 @@ describe('active-workers', () => { activeWorkers.clear(); mockFailOrphanedRun.mockReset(); mockFailOrphanedRun.mockResolvedValue(null); + mockFailOrphanedRunFallback.mockReset(); + mockFailOrphanedRunFallback.mockResolvedValue(null); mockClearWorkItemEnqueued.mockClear(); mockClearAgentTypeEnqueued.mockClear(); }); @@ -185,6 +190,8 @@ describe('active-workers', () => { 'proj-1', 'card-1', 'Worker crashed with exit code 1', + 'failed', + expect.any(Number), ); }); @@ -231,5 +238,61 @@ describe('active-workers', () => { cleanupWorker('job-no-agent', 1); expect(mockClearWorkItemEnqueued).not.toHaveBeenCalled(); }); + + it('calls failOrphanedRunFallback when no workItemId but projectId exists', () => { + mockFailOrphanedRunFallback.mockResolvedValue('run-fallback'); + const startedAt = new Date(); + activeWorkers.set( + 'job-no-wi', + makeActiveWorker({ + jobId: 'job-no-wi', + projectId: 'proj-1', + startedAt, + agentType: 'review', + // no workItemId + }), + ); + + cleanupWorker('job-no-wi', 1); + expect(mockFailOrphanedRunFallback).toHaveBeenCalledWith( + 'proj-1', + 'review', + startedAt, + 'failed', + 'Worker crashed with exit code 1', + expect.any(Number), + ); + expect(mockFailOrphanedRun).not.toHaveBeenCalled(); + }); + + it('calls failOrphanedRunFallback with undefined agentType when both absent', () => { + mockFailOrphanedRunFallback.mockResolvedValue('run-fallback2'); + activeWorkers.set( + 'job-no-wi-no-agent', + makeActiveWorker({ + jobId: 'job-no-wi-no-agent', + projectId: 'proj-1', + // no workItemId, no agentType + }), + ); + + cleanupWorker('job-no-wi-no-agent', 1); + expect(mockFailOrphanedRunFallback).toHaveBeenCalled(); + expect(mockFailOrphanedRun).not.toHaveBeenCalled(); + }); + + it('does NOT call either fail function when projectId is missing', () => { + activeWorkers.set( + 'job-no-proj', + makeActiveWorker({ + jobId: 'job-no-proj', + // no projectId, no workItemId + }), + ); + + cleanupWorker('job-no-proj', 1); + expect(mockFailOrphanedRun).not.toHaveBeenCalled(); + expect(mockFailOrphanedRunFallback).not.toHaveBeenCalled(); + }); }); }); diff --git a/tests/unit/router/container-manager.test.ts b/tests/unit/router/container-manager.test.ts index fb29adac..bfae019d 100644 --- a/tests/unit/router/container-manager.test.ts +++ b/tests/unit/router/container-manager.test.ts @@ -4,13 +4,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Hoisted mock state — vi.hoisted creates variables before vi.mock factories run // --------------------------------------------------------------------------- -const { mockDockerCreateContainer, mockDockerGetContainer, mockDockerListContainers } = vi.hoisted( - () => ({ - mockDockerCreateContainer: vi.fn(), - mockDockerGetContainer: vi.fn(), - mockDockerListContainers: vi.fn(), - }), -); +const { + mockDockerCreateContainer, + mockDockerGetContainer, + mockDockerListContainers, + mockLoadProjectConfig, +} = vi.hoisted(() => ({ + mockDockerCreateContainer: vi.fn(), + mockDockerGetContainer: vi.fn(), + mockDockerListContainers: vi.fn(), + mockLoadProjectConfig: vi.fn().mockResolvedValue({ projects: [], fullProjects: [] }), +})); // --------------------------------------------------------------------------- // Module-level mocks @@ -34,8 +38,10 @@ vi.mock('../../../src/config/provider.js', () => ({ })); const mockFailOrphanedRun = vi.fn().mockResolvedValue(null); +const mockFailOrphanedRunFallback = vi.fn().mockResolvedValue(null); vi.mock('../../../src/db/repositories/runsRepository.js', () => ({ failOrphanedRun: (...args: unknown[]) => mockFailOrphanedRun(...args), + failOrphanedRunFallback: (...args: unknown[]) => mockFailOrphanedRunFallback(...args), })); vi.mock('../../../src/config/configCache.js', () => ({ @@ -70,6 +76,7 @@ vi.mock('../../../src/router/config.js', () => ({ workerTimeoutMs: 5000, dockerNetwork: 'test-network', }, + loadProjectConfig: (...args: unknown[]) => mockLoadProjectConfig(...args), })); // --------------------------------------------------------------------------- @@ -238,6 +245,7 @@ describe('spawnWorker', () => { vi.spyOn(console, 'warn').mockImplementation(() => {}); vi.spyOn(console, 'error').mockImplementation(() => {}); mockGetAllProjectCredentials.mockResolvedValue({}); + mockLoadProjectConfig.mockResolvedValue({ projects: [], fullProjects: [] }); detachAll(); }); @@ -296,6 +304,52 @@ describe('spawnWorker', () => { expect(getActiveWorkerCount()).toBe(countBefore); }); + + it('includes cascade.project.id label in container config', async () => { + const { resolveWait } = setupMockContainer(); + + await spawnWorker( + makeJob({ + id: 'job-label', + data: { type: 'trello', projectId: 'proj-42' } as CascadeJob, + }) as never, + ); + + expect(mockDockerCreateContainer).toHaveBeenCalledWith( + expect.objectContaining({ + Labels: expect.objectContaining({ + 'cascade.project.id': 'proj-42', + 'cascade.managed': 'true', + 'cascade.agent.type': '', + }), + }), + ); + + resolveWait(); + }); + + it('uses project watchdogTimeoutMs + 2min buffer when available', async () => { + mockLoadProjectConfig.mockResolvedValue({ + projects: [], + fullProjects: [{ id: 'proj-1', watchdogTimeoutMs: 10000 }], + }); + vi.useFakeTimers(); + const { container, resolveWait } = setupMockContainer(); + + await spawnWorker(makeJob() as never); + + // At watchdogTimeoutMs + 2min - 1ms: should NOT yet have triggered kill + vi.advanceTimersByTime(10000 + 2 * 60 * 1000 - 1); + expect(container.stop).not.toHaveBeenCalled(); + + // One more ms: should trigger killWorker → container.stop + await vi.advanceTimersByTimeAsync(1); + expect(container.stop).toHaveBeenCalled(); + + resolveWait(); + vi.useRealTimers(); + mockLoadProjectConfig.mockResolvedValue({ projects: [], fullProjects: [] }); + }); }); // --------------------------------------------------------------------------- @@ -307,6 +361,9 @@ describe('killWorker', () => { vi.spyOn(console, 'log').mockImplementation(() => {}); vi.spyOn(console, 'warn').mockImplementation(() => {}); mockGetAllProjectCredentials.mockResolvedValue({}); + mockLoadProjectConfig.mockResolvedValue({ projects: [], fullProjects: [] }); + mockFailOrphanedRun.mockResolvedValue(null); + mockFailOrphanedRunFallback.mockResolvedValue(null); mockNotifyTimeout.mockResolvedValue(undefined); detachAll(); }); @@ -356,6 +413,61 @@ describe('killWorker', () => { resolveWait(); }); + + it('calls failOrphanedRunFallback on kill when worker has no workItemId', async () => { + mockFailOrphanedRunFallback.mockResolvedValue('run-kill-fallback'); + const { resolveWait } = setupMockContainer(); + + // Default job: projectId='proj-1', no workItemId + await spawnWorker(makeJob({ id: 'job-kill-fallback' }) as never); + await killWorker('job-kill-fallback'); + + // Fire-and-forget — flush microtasks + await new Promise((r) => setTimeout(r, 10)); + expect(mockFailOrphanedRunFallback).toHaveBeenCalledWith( + 'proj-1', + undefined, // no agentType on default job + expect.any(Date), + 'timed_out', + 'Router timeout', + expect.any(Number), + ); + // Verify no double-call (cleanupWorker must NOT also trigger a DB update) + expect(mockFailOrphanedRunFallback).toHaveBeenCalledTimes(1); + + resolveWait(); + }); + + it('calls failOrphanedRun with timed_out on kill when worker has workItemId', async () => { + mockFailOrphanedRun.mockResolvedValue('run-kill-wi'); + const { resolveWait } = setupMockContainer(); + + await spawnWorker( + makeJob({ + id: 'job-kill-wi', + data: { + type: 'trello', + projectId: 'proj-1', + workItemId: 'card-1', + } as CascadeJob, + }) as never, + ); + await killWorker('job-kill-wi'); + + // Fire-and-forget — flush microtasks + await new Promise((r) => setTimeout(r, 10)); + expect(mockFailOrphanedRun).toHaveBeenCalledWith( + 'proj-1', + 'card-1', + 'Router timeout', + 'timed_out', + expect.any(Number), + ); + // Verify no double-call (cleanupWorker must NOT also trigger a DB update) + expect(mockFailOrphanedRun).toHaveBeenCalledTimes(1); + + resolveWait(); + }); }); // --------------------------------------------------------------------------- @@ -365,7 +477,10 @@ describe('killWorker', () => { describe('cleanupWorker', () => { beforeEach(() => { vi.spyOn(console, 'log').mockImplementation(() => {}); - mockFailOrphanedRun.mockClear(); + mockGetAllProjectCredentials.mockResolvedValue({}); + mockLoadProjectConfig.mockResolvedValue({ projects: [], fullProjects: [] }); + mockFailOrphanedRun.mockResolvedValue(null); + mockFailOrphanedRunFallback.mockResolvedValue(null); detachAll(); }); @@ -420,6 +535,8 @@ describe('cleanupWorker', () => { 'proj-1', 'card-1', 'Worker crashed with exit code 1', + 'failed', + expect.any(Number), ); resolveWait(); @@ -467,6 +584,8 @@ describe('cleanupWorker', () => { 'proj-1', 'card-1', 'Worker crashed with exit code 1', + 'failed', + expect.any(Number), ); resolveWait(); @@ -497,6 +616,7 @@ describe('detachAll', () => { beforeEach(() => { vi.spyOn(console, 'log').mockImplementation(() => {}); mockGetAllProjectCredentials.mockResolvedValue({}); + mockLoadProjectConfig.mockResolvedValue({ projects: [], fullProjects: [] }); detachAll(); }); @@ -545,6 +665,7 @@ describe('orphan cleanup', () => { vi.spyOn(console, 'info').mockImplementation(() => {}); vi.spyOn(console, 'error').mockImplementation(() => {}); mockGetAllProjectCredentials.mockResolvedValue({}); + mockLoadProjectConfig.mockResolvedValue({ projects: [], fullProjects: [] }); mockDockerListContainers.mockResolvedValue([]); detachAll(); }); diff --git a/tests/unit/router/orphan-cleanup.test.ts b/tests/unit/router/orphan-cleanup.test.ts index 986c6810..020ecb42 100644 --- a/tests/unit/router/orphan-cleanup.test.ts +++ b/tests/unit/router/orphan-cleanup.test.ts @@ -4,10 +4,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Hoisted mock state — vi.hoisted creates variables before vi.mock factories run // --------------------------------------------------------------------------- -const { mockDockerGetContainer, mockDockerListContainers } = vi.hoisted(() => ({ - mockDockerGetContainer: vi.fn(), - mockDockerListContainers: vi.fn(), -})); +const { mockDockerGetContainer, mockDockerListContainers, mockFailOrphanedRunFallback } = + vi.hoisted(() => ({ + mockDockerGetContainer: vi.fn(), + mockDockerListContainers: vi.fn(), + mockFailOrphanedRunFallback: vi.fn().mockResolvedValue(null), + })); // --------------------------------------------------------------------------- // Module-level mocks @@ -20,6 +22,10 @@ vi.mock('dockerode', () => ({ })), })); +vi.mock('../../../src/db/repositories/runsRepository.js', () => ({ + failOrphanedRunFallback: (...args: unknown[]) => mockFailOrphanedRunFallback(...args), +})); + vi.mock('../../../src/sentry.js', () => ({ captureException: vi.fn(), })); @@ -63,6 +69,8 @@ describe('orphan-cleanup', () => { vi.spyOn(console, 'error').mockImplementation(() => {}); mockDockerListContainers.mockResolvedValue([]); mockTrackedIds.clear(); + mockFailOrphanedRunFallback.mockClear(); + mockFailOrphanedRunFallback.mockResolvedValue(null); }); afterEach(() => { @@ -243,6 +251,153 @@ describe('orphan-cleanup', () => { expect(mockContainer2.stop).toHaveBeenCalledWith({ t: 15 }); }); + it('calls failOrphanedRunFallback when container has cascade.project.id label', async () => { + const orphanContainerId = 'orphan-with-project'; + const now = Math.floor(Date.now() / 1000); + const createdAt = now - 6; // old enough + + const mockOrphanContainer = { + stop: vi.fn().mockResolvedValue(undefined), + }; + mockDockerListContainers.mockResolvedValue([ + { + Id: orphanContainerId, + Created: createdAt, + Labels: { 'cascade.project.id': 'proj-1' }, + State: 'running', + } as never, + ]); + mockDockerGetContainer.mockReturnValue(mockOrphanContainer as never); + mockFailOrphanedRunFallback.mockResolvedValue('run-orphan-1'); + + await scanAndCleanupOrphans(); + // Fire-and-forget — flush microtasks + await new Promise((r) => setTimeout(r, 10)); + + expect(mockFailOrphanedRunFallback).toHaveBeenCalledWith( + 'proj-1', + undefined, + expect.any(Date), + 'failed', + 'Orphan cleanup: container stopped', + expect.any(Number), + ); + }); + + it('does NOT call failOrphanedRunFallback when container has no cascade.project.id label', async () => { + const orphanContainerId = 'orphan-no-label'; + const now = Math.floor(Date.now() / 1000); + const createdAt = now - 6; + + const mockOrphanContainer = { + stop: vi.fn().mockResolvedValue(undefined), + }; + mockDockerListContainers.mockResolvedValue([ + { + Id: orphanContainerId, + Created: createdAt, + State: 'running', + } as never, + ]); + mockDockerGetContainer.mockReturnValue(mockOrphanContainer as never); + + await scanAndCleanupOrphans(); + await new Promise((r) => setTimeout(r, 10)); + + expect(mockFailOrphanedRunFallback).not.toHaveBeenCalled(); + }); + + it('does NOT call failOrphanedRunFallback when cascade.project.id label is empty string', async () => { + const orphanContainerId = 'orphan-empty-label'; + const now = Math.floor(Date.now() / 1000); + const createdAt = now - 6; + + const mockOrphanContainer = { + stop: vi.fn().mockResolvedValue(undefined), + }; + mockDockerListContainers.mockResolvedValue([ + { + Id: orphanContainerId, + Created: createdAt, + Labels: { 'cascade.project.id': '' }, // empty → falsy + State: 'running', + } as never, + ]); + mockDockerGetContainer.mockReturnValue(mockOrphanContainer as never); + + await scanAndCleanupOrphans(); + await new Promise((r) => setTimeout(r, 10)); + + expect(mockFailOrphanedRunFallback).not.toHaveBeenCalled(); + }); + + it('passes cascade.agent.type label as agentType to failOrphanedRunFallback', async () => { + const orphanContainerId = 'orphan-with-agent-type'; + const now = Math.floor(Date.now() / 1000); + const createdAt = now - 6; + + const mockOrphanContainer = { + stop: vi.fn().mockResolvedValue(undefined), + }; + mockDockerListContainers.mockResolvedValue([ + { + Id: orphanContainerId, + Created: createdAt, + Labels: { + 'cascade.project.id': 'proj-2', + 'cascade.agent.type': 'review', + }, + State: 'running', + } as never, + ]); + mockDockerGetContainer.mockReturnValue(mockOrphanContainer as never); + mockFailOrphanedRunFallback.mockResolvedValue('run-agent-type'); + + await scanAndCleanupOrphans(); + await new Promise((r) => setTimeout(r, 10)); + + expect(mockFailOrphanedRunFallback).toHaveBeenCalledWith( + 'proj-2', + 'review', + expect.any(Date), + 'failed', + 'Orphan cleanup: container stopped', + expect.any(Number), + ); + }); + + it('passes undefined agentType when cascade.agent.type label is empty or absent', async () => { + const orphanContainerId = 'orphan-no-agent-type'; + const now = Math.floor(Date.now() / 1000); + const createdAt = now - 6; + + const mockOrphanContainer = { + stop: vi.fn().mockResolvedValue(undefined), + }; + mockDockerListContainers.mockResolvedValue([ + { + Id: orphanContainerId, + Created: createdAt, + Labels: { 'cascade.project.id': 'proj-3', 'cascade.agent.type': '' }, + State: 'running', + } as never, + ]); + mockDockerGetContainer.mockReturnValue(mockOrphanContainer as never); + mockFailOrphanedRunFallback.mockResolvedValue(null); + + await scanAndCleanupOrphans(); + await new Promise((r) => setTimeout(r, 10)); + + expect(mockFailOrphanedRunFallback).toHaveBeenCalledWith( + 'proj-3', + undefined, // empty string coerced to undefined + expect.any(Date), + 'failed', + 'Orphan cleanup: container stopped', + expect.any(Number), + ); + }); + it('stops orphans but leaves tracked and young containers', async () => { const trackedId = 'container-tracked-123'; mockTrackedIds.add(trackedId); From cea359c3d69a9893aae016f009ddc922c02c83ef Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Mon, 16 Mar 2026 16:31:44 +0000 Subject: [PATCH 5/5] ci: trigger CI run