Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 79 additions & 18 deletions coderd/aitasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"golang.org/x/xerrors"

"cdr.dev/slog"

"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbtime"
Expand All @@ -23,6 +24,7 @@ import (
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/searchquery"
"github.com/coder/coder/v2/coderd/taskname"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"

Expand Down Expand Up @@ -270,37 +272,29 @@ func (api *API) tasksCreate(rw http.ResponseWriter, r *http.Request) {
func taskFromDBTaskAndWorkspace(dbTask database.Task, ws codersdk.Workspace) codersdk.Task {
var taskAgentLifecycle *codersdk.WorkspaceAgentLifecycle
var taskAgentHealth *codersdk.WorkspaceAgentHealth
var taskAppHealth *codersdk.WorkspaceAppHealth

if dbTask.WorkspaceAgentLifecycleState.Valid {
taskAgentLifecycle = ptr.Ref(codersdk.WorkspaceAgentLifecycle(dbTask.WorkspaceAgentLifecycleState.WorkspaceAgentLifecycleState))
}
if dbTask.WorkspaceAppHealth.Valid {
taskAppHealth = ptr.Ref(codersdk.WorkspaceAppHealth(dbTask.WorkspaceAppHealth.WorkspaceAppHealth))
}

// If we have an agent ID from the task, find the agent details in the
// workspace.
// If we have an agent ID from the task, find the agent health info
if dbTask.WorkspaceAgentID.Valid {
findTaskAgentLoop:
for _, resource := range ws.LatestBuild.Resources {
for _, agent := range resource.Agents {
if agent.ID == dbTask.WorkspaceAgentID.UUID {
taskAgentLifecycle = &agent.LifecycleState
taskAgentHealth = &agent.Health
break findTaskAgentLoop
}
}
}
}

// Ignore 'latest app status' if it is older than the latest build and the
// latest build is a 'start' transition. This ensures that you don't show a
// stale app status from a previous build. For stop transitions, there is
// still value in showing the latest app status.
var currentState *codersdk.TaskStateEntry
if ws.LatestAppStatus != nil {
if ws.LatestBuild.Transition != codersdk.WorkspaceTransitionStart || ws.LatestAppStatus.CreatedAt.After(ws.LatestBuild.CreatedAt) {
currentState = &codersdk.TaskStateEntry{
Timestamp: ws.LatestAppStatus.CreatedAt,
State: codersdk.TaskState(ws.LatestAppStatus.State),
Message: ws.LatestAppStatus.Message,
URI: ws.LatestAppStatus.URI,
}
}
}
currentState := deriveTaskCurrentState(dbTask, ws, taskAgentLifecycle, taskAppHealth)

return codersdk.Task{
ID: dbTask.ID,
Expand Down Expand Up @@ -330,6 +324,73 @@ func taskFromDBTaskAndWorkspace(dbTask database.Task, ws codersdk.Workspace) cod
}
}

// deriveTaskCurrentState determines the current state of a task based on the
// workspace's latest app status and initialization phase.
// Returns nil if no valid state can be determined.
func deriveTaskCurrentState(
dbTask database.Task,
ws codersdk.Workspace,
taskAgentLifecycle *codersdk.WorkspaceAgentLifecycle,
taskAppHealth *codersdk.WorkspaceAppHealth,
) *codersdk.TaskStateEntry {
var currentState *codersdk.TaskStateEntry

// Ignore 'latest app status' if it is older than the latest build and the
// latest build is a 'start' transition. This ensures that you don't show a
// stale app status from a previous build. For stop transitions, there is
// still value in showing the latest app status.
if ws.LatestAppStatus != nil {
if ws.LatestBuild.Transition != codersdk.WorkspaceTransitionStart || ws.LatestAppStatus.CreatedAt.After(ws.LatestBuild.CreatedAt) {
currentState = &codersdk.TaskStateEntry{
Timestamp: ws.LatestAppStatus.CreatedAt,
State: codersdk.TaskState(ws.LatestAppStatus.State),
Message: ws.LatestAppStatus.Message,
URI: ws.LatestAppStatus.URI,
}
}
}

// If no valid agent state was found for the current build and the task is initializing,
// provide a descriptive initialization message.
if currentState == nil && dbTask.Status == database.TaskStatusInitializing {
message := "Initializing workspace"

switch {
case ws.LatestBuild.Status == codersdk.WorkspaceStatusPending ||
ws.LatestBuild.Status == codersdk.WorkspaceStatusStarting:
message = fmt.Sprintf("Workspace is %s", ws.LatestBuild.Status)
case taskAgentLifecycle != nil:
switch {
case *taskAgentLifecycle == codersdk.WorkspaceAgentLifecycleCreated:
message = "Agent is connecting"
case *taskAgentLifecycle == codersdk.WorkspaceAgentLifecycleStarting:
message = "Agent is starting"
case *taskAgentLifecycle == codersdk.WorkspaceAgentLifecycleReady:
if taskAppHealth != nil && *taskAppHealth == codersdk.WorkspaceAppHealthInitializing {
message = "App is initializing"
} else {
// In case the workspace app is not initializing,
// the overall task status should be updated accordingly
message = "Initializing workspace applications"
}
default:
// In case the workspace agent is not initializing,
// the overall task status should be updated accordingly
message = "Initializing workspace agent"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should never happen, but the fallback is fine, should we log something if this happens?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this should never happen, similarly to the "Initializing workspace applications" path. I think adding a comment here is enough for now. Addressed in 677339d

}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we take into account the coder app status as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean LatestAppStatus? I thought about adding different messages based on the app status state, for example, if LatestAppStatus.State == idle, the message could be "Agent is idle", or for completed/failed states, we could provide more detailed information to users. Or were you thinking of something else?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking about WorkspaceAppHealth, specifically WorkspaceAppHealthInitializing. If disabled we can ignore it.

I'm not sure if we need to enrich LatestAppStatus if there already exists an entry 🤔. I imagine users could want to tweak that via having the agent give some context via the mcp report tool.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking about WorkspaceAppHealth, specifically WorkspaceAppHealthInitializing. If disabled we can ignore it.

Ah, I see. So this would be the case where the agent is ready but the app is still initializing, correct? Do you think it makes sense to get into that level of detail? We have different possible AppHealth statuses:

WorkspaceAppHealthDisabled     WorkspaceAppHealth = "disabled"
WorkspaceAppHealthInitializing WorkspaceAppHealth = "initializing"
WorkspaceAppHealthHealthy      WorkspaceAppHealth = "healthy"
WorkspaceAppHealthUnhealthy    WorkspaceAppHealth = "unhealthy"

I'm guessing only the initializing here would make sense, because the others would probably affect the overall task status, correct?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this would be the case where the agent is ready but the app is still initializing, correct?

Yep! 👍🏻

Do you think it makes sense to get into that level of detail?

It would better reflect how we determine the tasks status in the tasks_with_status view.

I'm guessing only the initializing here would make sense, because the others would probably affect the overall task status, correct?

Correct. 👍🏻

We could consider unhealthy as well, but that would open a can of worms I'd prefer to avoid for now (messages for agent health, workspace build failure, etc).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in b1cb7fe

}

currentState = &codersdk.TaskStateEntry{
Timestamp: ws.LatestBuild.CreatedAt,
State: codersdk.TaskStateWorking,
Message: message,
URI: "",
}
}

return currentState
}

// @Summary List AI tasks
// @Description: EXPERIMENTAL: this endpoint is experimental and not guaranteed to be stable.
// @ID list-tasks
Expand Down
223 changes: 223 additions & 0 deletions coderd/aitasks_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
package coderd

import (
"testing"
"time"

"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
)

func TestDeriveTaskCurrentState_Unit(t *testing.T) {
t.Parallel()

now := time.Now()
tests := []struct {
name string
task database.Task
agentLifecycle *codersdk.WorkspaceAgentLifecycle
appHealth *codersdk.WorkspaceAppHealth
latestAppStatus *codersdk.WorkspaceAppStatus
latestBuild codersdk.WorkspaceBuild
expectCurrentState bool
expectedTimestamp time.Time
expectedState codersdk.TaskState
expectedMessage string
}{
{
name: "NoAppStatus",
task: database.Task{
ID: uuid.New(),
Status: database.TaskStatusActive,
},
agentLifecycle: nil,
appHealth: nil,
latestAppStatus: nil,
latestBuild: codersdk.WorkspaceBuild{
Transition: codersdk.WorkspaceTransitionStart,
CreatedAt: now,
},
expectCurrentState: false,
},
{
name: "BuildStartTransition_AppStatus_NewerThanBuild",
task: database.Task{
ID: uuid.New(),
Status: database.TaskStatusActive,
},
agentLifecycle: nil,
appHealth: nil,
latestAppStatus: &codersdk.WorkspaceAppStatus{
State: codersdk.WorkspaceAppStatusStateWorking,
Message: "Task is working",
CreatedAt: now.Add(1 * time.Minute),
},
latestBuild: codersdk.WorkspaceBuild{
Transition: codersdk.WorkspaceTransitionStart,
CreatedAt: now,
},
expectCurrentState: true,
expectedTimestamp: now.Add(1 * time.Minute),
expectedState: codersdk.TaskState(codersdk.WorkspaceAppStatusStateWorking),
expectedMessage: "Task is working",
},
{
name: "BuildStartTransition_StaleAppStatus_OlderThanBuild",
task: database.Task{
ID: uuid.New(),
Status: database.TaskStatusActive,
},
agentLifecycle: nil,
appHealth: nil,
latestAppStatus: &codersdk.WorkspaceAppStatus{
State: codersdk.WorkspaceAppStatusStateComplete,
Message: "Previous task completed",
CreatedAt: now.Add(-1 * time.Minute),
},
latestBuild: codersdk.WorkspaceBuild{
Transition: codersdk.WorkspaceTransitionStart,
CreatedAt: now,
},
expectCurrentState: false,
},
{
name: "BuildStopTransition",
task: database.Task{
ID: uuid.New(),
Status: database.TaskStatusActive,
},
agentLifecycle: nil,
appHealth: nil,
latestAppStatus: &codersdk.WorkspaceAppStatus{
State: codersdk.WorkspaceAppStatusStateComplete,
Message: "Task completed before stop",
CreatedAt: now.Add(-1 * time.Minute),
},
latestBuild: codersdk.WorkspaceBuild{
Transition: codersdk.WorkspaceTransitionStop,
CreatedAt: now,
},
expectCurrentState: true,
expectedTimestamp: now.Add(-1 * time.Minute),
expectedState: codersdk.TaskState(codersdk.WorkspaceAppStatusStateComplete),
expectedMessage: "Task completed before stop",
},
{
name: "TaskInitializing_WorkspacePending",
task: database.Task{
ID: uuid.New(),
Status: database.TaskStatusInitializing,
},
agentLifecycle: nil,
appHealth: nil,
latestAppStatus: nil,
latestBuild: codersdk.WorkspaceBuild{
Status: codersdk.WorkspaceStatusPending,
CreatedAt: now,
},
expectCurrentState: true,
expectedTimestamp: now,
expectedState: codersdk.TaskStateWorking,
expectedMessage: "Workspace is pending",
},
{
name: "TaskInitializing_WorkspaceStarting",
task: database.Task{
ID: uuid.New(),
Status: database.TaskStatusInitializing,
},
agentLifecycle: nil,
appHealth: nil,
latestAppStatus: nil,
latestBuild: codersdk.WorkspaceBuild{
Status: codersdk.WorkspaceStatusStarting,
CreatedAt: now,
},
expectCurrentState: true,
expectedTimestamp: now,
expectedState: codersdk.TaskStateWorking,
expectedMessage: "Workspace is starting",
},
{
name: "TaskInitializing_AgentConnecting",
task: database.Task{
ID: uuid.New(),
Status: database.TaskStatusInitializing,
},
agentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleCreated),
appHealth: nil,
latestAppStatus: nil,
latestBuild: codersdk.WorkspaceBuild{
Status: codersdk.WorkspaceStatusRunning,
CreatedAt: now,
},
expectCurrentState: true,
expectedTimestamp: now,
expectedState: codersdk.TaskStateWorking,
expectedMessage: "Agent is connecting",
},
{
name: "TaskInitializing_AgentStarting",
task: database.Task{
ID: uuid.New(),
Status: database.TaskStatusInitializing,
},
agentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleStarting),
appHealth: nil,
latestAppStatus: nil,
latestBuild: codersdk.WorkspaceBuild{
Status: codersdk.WorkspaceStatusRunning,
CreatedAt: now,
},
expectCurrentState: true,
expectedTimestamp: now,
expectedState: codersdk.TaskStateWorking,
expectedMessage: "Agent is starting",
},
{
name: "TaskInitializing_AppInitializing",
task: database.Task{
ID: uuid.New(),
Status: database.TaskStatusInitializing,
},
agentLifecycle: ptr.Ref(codersdk.WorkspaceAgentLifecycleReady),
appHealth: ptr.Ref(codersdk.WorkspaceAppHealthInitializing),
latestAppStatus: nil,
latestBuild: codersdk.WorkspaceBuild{
Status: codersdk.WorkspaceStatusRunning,
CreatedAt: now,
},
expectCurrentState: true,
expectedTimestamp: now,
expectedState: codersdk.TaskStateWorking,
expectedMessage: "App is initializing",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

ws := codersdk.Workspace{
LatestBuild: tt.latestBuild,
LatestAppStatus: tt.latestAppStatus,
}

currentState := deriveTaskCurrentState(tt.task, ws, tt.agentLifecycle, tt.appHealth)

if tt.expectCurrentState {
require.NotNil(t, currentState)
assert.Equal(t, tt.expectedTimestamp.UTC(), currentState.Timestamp.UTC())
assert.Equal(t, tt.expectedState, currentState.State)
assert.Equal(t, tt.expectedMessage, currentState.Message)
} else {
assert.Nil(t, currentState)
}
})
}
}
8 changes: 6 additions & 2 deletions coderd/aitasks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,14 +240,18 @@ func TestTasks(t *testing.T) {
assert.NotNil(t, updated.CurrentState, "current state should not be nil")
assert.Equal(t, "all done", updated.CurrentState.Message)
assert.Equal(t, codersdk.TaskStateComplete, updated.CurrentState.State)
previousCurrentState := updated.CurrentState

// Start the workspace again
coderdtest.MustTransitionWorkspace(t, client, task.WorkspaceID.UUID, codersdk.WorkspaceTransitionStop, codersdk.WorkspaceTransitionStart)

// Verify that the status from the previous build is no longer present
// Verify that the status from the previous build has been cleared
// and replaced by the agent initialization status.
updated, err = exp.TaskByID(ctx, task.ID)
require.NoError(t, err)
assert.Nil(t, updated.CurrentState, "current state should be nil")
assert.NotEqual(t, previousCurrentState, updated.CurrentState)
assert.Equal(t, codersdk.TaskStateWorking, updated.CurrentState.State)
assert.NotEqual(t, "all done", updated.CurrentState.Message)
})

t.Run("Delete", func(t *testing.T) {
Expand Down
Loading
Loading