fix: use actual ai_task instances for HasAITasks (#25197)

Previously, `hasAITaskResources()` scanned the Terraform graph for
`coder_ai_task` node labels. The graph includes resource definitions
regardless of `count`, so templates with `count = 0` were incorrectly
marked as `HasAITasks = true`, causing them to appear on the `/tasks`
page when no AI task resources would be created.

Replace the graph-based check with `len(aiTasks) > 0`. The `aiTasks`
slice is populated from state modules where Terraform has already
evaluated `count`, so it correctly reflects actual resource instances.

ref:
https://linear.app/codercom/issue/ECO-39/make-coder-tasks-respect-count

> Generated with [Coder Agents](https://coder.com/agents)

---------

Signed-off-by: 35C4n0r <work.jaykumar@gmail.com>
Signed-off-by: Jay Kumar <jay.kumar@coder.com>
This commit is contained in:
35C4n0r
2026-05-14 21:49:05 +05:30
committed by GitHub
parent 9ddfafe2b1
commit 2871a02352
9 changed files with 218 additions and 37 deletions
+1 -22
View File
@@ -173,25 +173,6 @@ type State struct {
var ErrInvalidTerraformAddr = xerrors.New("invalid terraform address")
// hasAITaskResources is used to determine if a template has *any* `coder_ai_task` resources defined. During template
// import, it's possible that none of these have `count=1` since count may be dependent on the value of a `coder_parameter`
// or something else.
// We need to know at template import if these resources exist to inform the frontend of their existence.
func hasAITaskResources(graph *gographviz.Graph) bool {
for _, node := range graph.Nodes.Lookup {
// Check if this node is a coder_ai_task resource
if label, exists := node.Attrs["label"]; exists {
labelValue := strings.Trim(label, `"`)
// The first condition is for the case where the resource is in the root module.
// The second condition is for the case where the resource is in a child module.
if strings.HasPrefix(labelValue, "coder_ai_task.") || strings.Contains(labelValue, ".coder_ai_task.") {
return true
}
}
}
return false
}
func hasExternalAgentResources(graph *gographviz.Graph) bool {
for _, node := range graph.Nodes.Lookup {
if label, exists := node.Attrs["label"]; exists {
@@ -1083,14 +1064,12 @@ func ConvertState(ctx context.Context, modules []*tfjson.StateModule, rawGraph s
slices.SortFunc(externalAuthProviders, func(a, b *proto.ExternalAuthProviderResource) int {
return cmp.Compare(a.Id, b.Id)
})
hasAITasks := hasAITaskResources(graph)
return &State{
Resources: resources,
Parameters: parameters,
Presets: presets,
ExternalAuthProviders: externalAuthProviders,
HasAITasks: hasAITasks,
HasAITasks: len(aiTasks) > 0,
AITasks: aiTasks,
HasExternalAgents: hasExternalAgentResources(graph),
}, nil
+26
View File
@@ -1764,6 +1764,32 @@ func TestAITasks(t *testing.T) {
require.Equal(t, "5ece4674-dd35-4f16-88c8-82e40e72e2fd", sidebarApp.GetId())
require.Equal(t, "5ece4674-dd35-4f16-88c8-82e40e72e2fd", state.AITasks[0].AppId)
})
t.Run("Disabled with count zero", func(t *testing.T) {
t.Parallel()
// nolint:dogsled
_, filename, _, _ := runtime.Caller(0)
// This fixture has coder_ai_task.a in the graph (resource is defined
// in the .tf file) but NOT in PlannedValues (count = 0). The old
// graph-based check returned true here; the new len(aiTasks) > 0
// check should return false.
dir := filepath.Join(filepath.Dir(filename), "testdata", "resources", "ai-tasks-disabled")
tfPlanRaw, err := os.ReadFile(filepath.Join(dir, "ai-tasks-disabled.tfplan.json"))
require.NoError(t, err)
var tfPlan tfjson.Plan
err = json.Unmarshal(tfPlanRaw, &tfPlan)
require.NoError(t, err)
tfPlanGraph, err := os.ReadFile(filepath.Join(dir, "ai-tasks-disabled.tfplan.dot"))
require.NoError(t, err)
state, err := terraform.ConvertState(ctx, []*tfjson.StateModule{tfPlan.PlannedValues.RootModule, tfPlan.PriorState.Values.RootModule}, string(tfPlanGraph), logger)
require.NotNil(t, state)
require.NoError(t, err)
require.False(t, state.HasAITasks)
require.Empty(t, state.AITasks)
})
}
func TestExternalAgents(t *testing.T) {
@@ -0,0 +1,20 @@
digraph {
compound = "true"
newrank = "true"
subgraph "root" {
"[root] coder_ai_task.a (expand)" [label = "coder_ai_task.a", shape = "box"]
"[root] data.coder_provisioner.me (expand)" [label = "data.coder_provisioner.me", shape = "box"]
"[root] data.coder_workspace.me (expand)" [label = "data.coder_workspace.me", shape = "box"]
"[root] data.coder_workspace_owner.me (expand)" [label = "data.coder_workspace_owner.me", shape = "box"]
"[root] provider[\"registry.terraform.io/coder/coder\"]" [label = "provider[\"registry.terraform.io/coder/coder\"]", shape = "diamond"]
"[root] coder_ai_task.a (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]"
"[root] data.coder_provisioner.me (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]"
"[root] data.coder_workspace.me (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]"
"[root] data.coder_workspace_owner.me (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]"
"[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] coder_ai_task.a (expand)"
"[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_provisioner.me (expand)"
"[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_workspace.me (expand)"
"[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_workspace_owner.me (expand)"
"[root] root" -> "[root] provider[\"registry.terraform.io/coder/coder\"] (close)"
}
}
@@ -0,0 +1,139 @@
{
"format_version": "1.2",
"terraform_version": "1.15.2",
"planned_values": {
"root_module": {}
},
"prior_state": {
"format_version": "1.0",
"terraform_version": "1.15.2",
"values": {
"root_module": {
"resources": [
{
"address": "data.coder_provisioner.me",
"mode": "data",
"type": "coder_provisioner",
"name": "me",
"provider_name": "registry.terraform.io/coder/coder",
"schema_version": 1,
"values": {
"arch": "amd64",
"id": "4bd4900e-85ab-4f4e-9378-153f9630d2aa",
"os": "linux"
},
"sensitive_values": {}
},
{
"address": "data.coder_workspace.me",
"mode": "data",
"type": "coder_workspace",
"name": "me",
"provider_name": "registry.terraform.io/coder/coder",
"schema_version": 1,
"values": {
"access_port": 443,
"access_url": "https://dev.coder.com/",
"id": "f8c4851f-dcbd-48bc-9a14-3fd506f8f015",
"is_prebuild": false,
"is_prebuild_claim": false,
"name": "ai-task-plan-check",
"prebuild_count": 0,
"start_count": 1,
"template_id": "",
"template_name": "",
"template_version": "",
"transition": "start"
},
"sensitive_values": {}
},
{
"address": "data.coder_workspace_owner.me",
"mode": "data",
"type": "coder_workspace_owner",
"name": "me",
"provider_name": "registry.terraform.io/coder/coder",
"schema_version": 0,
"values": {
"email": "default@example.com",
"full_name": "default",
"groups": [],
"id": "33769beb-1777-4d16-8774-2da632ca9611",
"login_type": null,
"name": "default",
"oidc_access_token": "",
"rbac_roles": [],
"session_token": "",
"ssh_private_key": "",
"ssh_public_key": ""
},
"sensitive_values": {
"groups": [],
"oidc_access_token": true,
"rbac_roles": [],
"session_token": true,
"ssh_private_key": true
}
}
]
}
}
},
"configuration": {
"provider_config": {
"coder": {
"name": "coder",
"full_name": "registry.terraform.io/coder/coder",
"version_constraint": ">= 2.0.0"
}
},
"root_module": {
"resources": [
{
"address": "coder_ai_task.a",
"mode": "managed",
"type": "coder_ai_task",
"name": "a",
"provider_config_key": "coder",
"expressions": {
"app_id": {
"constant_value": "5ece4674-dd35-4f16-88c8-82e40e72e2fd"
}
},
"schema_version": 1,
"count_expression": {
"constant_value": 0
}
},
{
"address": "data.coder_provisioner.me",
"mode": "data",
"type": "coder_provisioner",
"name": "me",
"provider_config_key": "coder",
"schema_version": 1
},
{
"address": "data.coder_workspace.me",
"mode": "data",
"type": "coder_workspace",
"name": "me",
"provider_config_key": "coder",
"schema_version": 1
},
{
"address": "data.coder_workspace_owner.me",
"mode": "data",
"type": "coder_workspace_owner",
"name": "me",
"provider_config_key": "coder",
"schema_version": 0
}
]
}
},
"timestamp": "2026-05-13T11:32:56Z",
"applyable": false,
"complete": true,
"errored": false
}
@@ -0,0 +1,9 @@
{
"Resources": [],
"Parameters": [],
"Presets": [],
"ExternalAuthProviders": [],
"AITasks": [],
"HasAITasks": false,
"HasExternalAgents": false
}
@@ -0,0 +1,17 @@
terraform {
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.0.0"
}
}
}
data "coder_provisioner" "me" {}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
resource "coder_ai_task" "a" {
count = 0
app_id = "5ece4674-dd35-4f16-88c8-82e40e72e2fd"
}
+2 -5
View File
@@ -4030,11 +4030,8 @@ type GraphComplete struct {
Parameters []*RichParameter `protobuf:"bytes,4,rep,name=parameters,proto3" json:"parameters,omitempty"`
ExternalAuthProviders []*ExternalAuthProviderResource `protobuf:"bytes,5,rep,name=external_auth_providers,json=externalAuthProviders,proto3" json:"external_auth_providers,omitempty"`
Presets []*Preset `protobuf:"bytes,6,rep,name=presets,proto3" json:"presets,omitempty"`
// Whether a template has any `coder_ai_task` resources defined, even if not planned for creation.
// During a template import, a plan is run which may not yield in any `coder_ai_task` resources, but nonetheless we
// still need to know that such resources are defined.
//
// See `hasAITaskResources` in provisioner/terraform/resources.go for more details.
// Whether actual `coder_ai_task` resource instances exist.
// Resources defined with count = 0 do not set this flag.
HasAiTasks bool `protobuf:"varint,7,opt,name=has_ai_tasks,json=hasAiTasks,proto3" json:"has_ai_tasks,omitempty"`
AiTasks []*AITask `protobuf:"bytes,8,rep,name=ai_tasks,json=aiTasks,proto3" json:"ai_tasks,omitempty"`
HasExternalAgents bool `protobuf:"varint,9,opt,name=has_external_agents,json=hasExternalAgents,proto3" json:"has_external_agents,omitempty"`
+2 -5
View File
@@ -504,11 +504,8 @@ message GraphComplete {
repeated RichParameter parameters = 4;
repeated ExternalAuthProviderResource external_auth_providers = 5;
repeated Preset presets = 6;
// Whether a template has any `coder_ai_task` resources defined, even if not planned for creation.
// During a template import, a plan is run which may not yield in any `coder_ai_task` resources, but nonetheless we
// still need to know that such resources are defined.
//
// See `hasAITaskResources` in provisioner/terraform/resources.go for more details.
// Whether actual `coder_ai_task` resource instances exist.
// Resources defined with count = 0 do not set this flag.
bool has_ai_tasks = 7;
repeated provisioner.AITask ai_tasks = 8;
bool has_external_agents = 9;
+2 -5
View File
@@ -562,11 +562,8 @@ export interface GraphComplete {
externalAuthProviders: ExternalAuthProviderResource[];
presets: Preset[];
/**
* Whether a template has any `coder_ai_task` resources defined, even if not planned for creation.
* During a template import, a plan is run which may not yield in any `coder_ai_task` resources, but nonetheless we
* still need to know that such resources are defined.
*
* See `hasAITaskResources` in provisioner/terraform/resources.go for more details.
* Whether actual `coder_ai_task` resource instances exist.
* Resources defined with count = 0 do not set this flag.
*/
hasAiTasks: boolean;
aiTasks: AITask[];