From 4cf8d4414ef1e32182104c3fbb85552f984ee98d Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Sat, 7 Mar 2026 01:36:03 +0000 Subject: [PATCH] feat: make `coder task send` resume paused tasks (#22203) --- cli/task_logs_test.go | 24 +-- cli/task_pause_test.go | 48 ++--- cli/task_resume_test.go | 74 +++---- cli/task_send.go | 163 +++++++++++++- cli/task_send_test.go | 237 +++++++++++++++++++-- cli/task_test.go | 61 +++++- cli/testdata/coder_task_send_--help.golden | 7 +- coderd/aitasks.go | 14 +- docs/reference/cli/task_send.md | 5 +- 9 files changed, 520 insertions(+), 113 deletions(-) diff --git a/cli/task_logs_test.go b/cli/task_logs_test.go index fefbd70bf4..6a54c60e62 100644 --- a/cli/task_logs_test.go +++ b/cli/task_logs_test.go @@ -41,11 +41,11 @@ func Test_TaskLogs_Golden(t *testing.T) { t.Parallel() setupCtx := testutil.Context(t, testutil.WaitLong) - _, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages)) + setup := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages)) - inv, root := clitest.New(t, "task", "logs", task.Name, "--output", "json") + inv, root := clitest.New(t, "task", "logs", setup.task.Name, "--output", "json") output := clitest.Capture(inv) - clitest.SetupConfig(t, userClient, root) + clitest.SetupConfig(t, setup.userClient, root) ctx := testutil.Context(t, testutil.WaitLong) err := inv.WithContext(ctx).Run() @@ -64,11 +64,11 @@ func Test_TaskLogs_Golden(t *testing.T) { t.Parallel() setupCtx := testutil.Context(t, testutil.WaitLong) - _, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages)) + setup := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages)) - inv, root := clitest.New(t, "task", "logs", task.ID.String(), "--output", "json") + inv, root := clitest.New(t, "task", "logs", setup.task.ID.String(), "--output", "json") output := clitest.Capture(inv) - clitest.SetupConfig(t, userClient, root) + clitest.SetupConfig(t, setup.userClient, root) ctx := testutil.Context(t, testutil.WaitLong) err := inv.WithContext(ctx).Run() @@ -87,11 +87,11 @@ func Test_TaskLogs_Golden(t *testing.T) { t.Parallel() setupCtx := testutil.Context(t, testutil.WaitLong) - _, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages)) + setup := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages)) - inv, root := clitest.New(t, "task", "logs", task.ID.String()) + inv, root := clitest.New(t, "task", "logs", setup.task.ID.String()) output := clitest.Capture(inv) - clitest.SetupConfig(t, userClient, root) + clitest.SetupConfig(t, setup.userClient, root) ctx := testutil.Context(t, testutil.WaitLong) err := inv.WithContext(ctx).Run() @@ -141,10 +141,10 @@ func Test_TaskLogs_Golden(t *testing.T) { t.Parallel() setupCtx := testutil.Context(t, testutil.WaitLong) - _, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsErr(assert.AnError)) + setup := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsErr(assert.AnError)) - inv, root := clitest.New(t, "task", "logs", task.ID.String()) - clitest.SetupConfig(t, userClient, root) + inv, root := clitest.New(t, "task", "logs", setup.task.ID.String()) + clitest.SetupConfig(t, setup.userClient, root) ctx := testutil.Context(t, testutil.WaitLong) err := inv.WithContext(ctx).Run() diff --git a/cli/task_pause_test.go b/cli/task_pause_test.go index 761590a859..83151a8457 100644 --- a/cli/task_pause_test.go +++ b/cli/task_pause_test.go @@ -7,7 +7,6 @@ import ( "github.com/stretchr/testify/require" "github.com/coder/coder/v2/cli/clitest" - "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/pty/ptytest" "github.com/coder/coder/v2/testutil" @@ -21,12 +20,12 @@ func TestExpTaskPause(t *testing.T) { // Given: A running task setupCtx := testutil.Context(t, testutil.WaitLong) - _, userClient, task := setupCLITaskTest(setupCtx, t, nil) + setup := setupCLITaskTest(setupCtx, t, nil) // When: We attempt to pause the task - inv, root := clitest.New(t, "task", "pause", task.Name, "--yes") + inv, root := clitest.New(t, "task", "pause", setup.task.Name, "--yes") output := clitest.Capture(inv) - clitest.SetupConfig(t, userClient, root) + clitest.SetupConfig(t, setup.userClient, root) // Then: Expect the task to be paused ctx := testutil.Context(t, testutil.WaitMedium) @@ -34,7 +33,7 @@ func TestExpTaskPause(t *testing.T) { require.NoError(t, err) require.Contains(t, output.Stdout(), "has been paused") - updated, err := userClient.TaskByIdentifier(ctx, task.Name) + updated, err := setup.userClient.TaskByIdentifier(ctx, setup.task.Name) require.NoError(t, err) require.Equal(t, codersdk.TaskStatusPaused, updated.Status) }) @@ -46,13 +45,13 @@ func TestExpTaskPause(t *testing.T) { // Given: A different user's running task setupCtx := testutil.Context(t, testutil.WaitLong) - adminClient, _, task := setupCLITaskTest(setupCtx, t, nil) + setup := setupCLITaskTest(setupCtx, t, nil) // When: We attempt to pause their task - identifier := fmt.Sprintf("%s/%s", task.OwnerName, task.Name) + identifier := fmt.Sprintf("%s/%s", setup.task.OwnerName, setup.task.Name) inv, root := clitest.New(t, "task", "pause", identifier, "--yes") output := clitest.Capture(inv) - clitest.SetupConfig(t, adminClient, root) + clitest.SetupConfig(t, setup.ownerClient, root) // Then: We expect the task to be paused ctx := testutil.Context(t, testutil.WaitMedium) @@ -60,7 +59,7 @@ func TestExpTaskPause(t *testing.T) { require.NoError(t, err) require.Contains(t, output.Stdout(), "has been paused") - updated, err := adminClient.TaskByIdentifier(ctx, identifier) + updated, err := setup.ownerClient.TaskByIdentifier(ctx, identifier) require.NoError(t, err) require.Equal(t, codersdk.TaskStatusPaused, updated.Status) }) @@ -70,11 +69,11 @@ func TestExpTaskPause(t *testing.T) { // Given: A running task setupCtx := testutil.Context(t, testutil.WaitLong) - _, userClient, task := setupCLITaskTest(setupCtx, t, nil) + setup := setupCLITaskTest(setupCtx, t, nil) // When: We attempt to pause the task - inv, root := clitest.New(t, "task", "pause", task.Name) - clitest.SetupConfig(t, userClient, root) + inv, root := clitest.New(t, "task", "pause", setup.task.Name) + clitest.SetupConfig(t, setup.userClient, root) // And: We confirm we want to pause the task ctx := testutil.Context(t, testutil.WaitMedium) @@ -88,7 +87,7 @@ func TestExpTaskPause(t *testing.T) { pty.ExpectMatchContext(ctx, "has been paused") require.NoError(t, w.Wait()) - updated, err := userClient.TaskByIdentifier(ctx, task.Name) + updated, err := setup.userClient.TaskByIdentifier(ctx, setup.task.Name) require.NoError(t, err) require.Equal(t, codersdk.TaskStatusPaused, updated.Status) }) @@ -98,11 +97,11 @@ func TestExpTaskPause(t *testing.T) { // Given: A running task setupCtx := testutil.Context(t, testutil.WaitLong) - _, userClient, task := setupCLITaskTest(setupCtx, t, nil) + setup := setupCLITaskTest(setupCtx, t, nil) // When: We attempt to pause the task - inv, root := clitest.New(t, "task", "pause", task.Name) - clitest.SetupConfig(t, userClient, root) + inv, root := clitest.New(t, "task", "pause", setup.task.Name) + clitest.SetupConfig(t, setup.userClient, root) // But: We say no at the confirmation screen ctx := testutil.Context(t, testutil.WaitMedium) @@ -114,7 +113,7 @@ func TestExpTaskPause(t *testing.T) { require.Error(t, w.Wait()) // Then: We expect the task to not be paused - updated, err := userClient.TaskByIdentifier(ctx, task.Name) + updated, err := setup.userClient.TaskByIdentifier(ctx, setup.task.Name) require.NoError(t, err) require.NotEqual(t, codersdk.TaskStatusPaused, updated.Status) }) @@ -124,21 +123,18 @@ func TestExpTaskPause(t *testing.T) { // Given: A running task setupCtx := testutil.Context(t, testutil.WaitLong) - _, userClient, task := setupCLITaskTest(setupCtx, t, nil) + setup := setupCLITaskTest(setupCtx, t, nil) // And: We paused the running task - ctx := testutil.Context(t, testutil.WaitMedium) - resp, err := userClient.PauseTask(ctx, task.OwnerName, task.ID) - require.NoError(t, err) - require.NotNil(t, resp.WorkspaceBuild) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, resp.WorkspaceBuild.ID) + pauseTask(setupCtx, t, setup.userClient, setup.task) // When: We attempt to pause the task again - inv, root := clitest.New(t, "task", "pause", task.Name, "--yes") - clitest.SetupConfig(t, userClient, root) + inv, root := clitest.New(t, "task", "pause", setup.task.Name, "--yes") + clitest.SetupConfig(t, setup.userClient, root) // Then: We expect to get an error that the task is already paused - err = inv.WithContext(ctx).Run() + ctx := testutil.Context(t, testutil.WaitMedium) + err := inv.WithContext(ctx).Run() require.ErrorContains(t, err, "is already paused") }) } diff --git a/cli/task_resume_test.go b/cli/task_resume_test.go index ec3712ec60..8ed8c42ece 100644 --- a/cli/task_resume_test.go +++ b/cli/task_resume_test.go @@ -1,7 +1,6 @@ package cli_test import ( - "context" "fmt" "testing" @@ -17,29 +16,18 @@ import ( func TestExpTaskResume(t *testing.T) { t.Parallel() - // pauseTask is a helper that pauses a task and waits for the stop - // build to complete. - pauseTask := func(ctx context.Context, t *testing.T, client *codersdk.Client, task codersdk.Task) { - t.Helper() - - pauseResp, err := client.PauseTask(ctx, task.OwnerName, task.ID) - require.NoError(t, err) - require.NotNil(t, pauseResp.WorkspaceBuild) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, pauseResp.WorkspaceBuild.ID) - } - t.Run("WithYesFlag", func(t *testing.T) { t.Parallel() // Given: A paused task setupCtx := testutil.Context(t, testutil.WaitLong) - _, userClient, task := setupCLITaskTest(setupCtx, t, nil) - pauseTask(setupCtx, t, userClient, task) + setup := setupCLITaskTest(setupCtx, t, nil) + pauseTask(setupCtx, t, setup.userClient, setup.task) // When: We attempt to resume the task - inv, root := clitest.New(t, "task", "resume", task.Name, "--yes") + inv, root := clitest.New(t, "task", "resume", setup.task.Name, "--yes") output := clitest.Capture(inv) - clitest.SetupConfig(t, userClient, root) + clitest.SetupConfig(t, setup.userClient, root) // Then: We expect the task to be resumed ctx := testutil.Context(t, testutil.WaitMedium) @@ -47,7 +35,7 @@ func TestExpTaskResume(t *testing.T) { require.NoError(t, err) require.Contains(t, output.Stdout(), "has been resumed") - updated, err := userClient.TaskByIdentifier(ctx, task.Name) + updated, err := setup.userClient.TaskByIdentifier(ctx, setup.task.Name) require.NoError(t, err) require.Equal(t, codersdk.TaskStatusInitializing, updated.Status) }) @@ -59,14 +47,14 @@ func TestExpTaskResume(t *testing.T) { // Given: A different user's paused task setupCtx := testutil.Context(t, testutil.WaitLong) - adminClient, userClient, task := setupCLITaskTest(setupCtx, t, nil) - pauseTask(setupCtx, t, userClient, task) + setup := setupCLITaskTest(setupCtx, t, nil) + pauseTask(setupCtx, t, setup.userClient, setup.task) // When: We attempt to resume their task - identifier := fmt.Sprintf("%s/%s", task.OwnerName, task.Name) + identifier := fmt.Sprintf("%s/%s", setup.task.OwnerName, setup.task.Name) inv, root := clitest.New(t, "task", "resume", identifier, "--yes") output := clitest.Capture(inv) - clitest.SetupConfig(t, adminClient, root) + clitest.SetupConfig(t, setup.ownerClient, root) // Then: We expect the task to be resumed ctx := testutil.Context(t, testutil.WaitMedium) @@ -74,7 +62,7 @@ func TestExpTaskResume(t *testing.T) { require.NoError(t, err) require.Contains(t, output.Stdout(), "has been resumed") - updated, err := adminClient.TaskByIdentifier(ctx, identifier) + updated, err := setup.ownerClient.TaskByIdentifier(ctx, identifier) require.NoError(t, err) require.Equal(t, codersdk.TaskStatusInitializing, updated.Status) }) @@ -84,13 +72,13 @@ func TestExpTaskResume(t *testing.T) { // Given: A paused task setupCtx := testutil.Context(t, testutil.WaitLong) - _, userClient, task := setupCLITaskTest(setupCtx, t, nil) - pauseTask(setupCtx, t, userClient, task) + setup := setupCLITaskTest(setupCtx, t, nil) + pauseTask(setupCtx, t, setup.userClient, setup.task) // When: We attempt to resume the task (and specify no wait) - inv, root := clitest.New(t, "task", "resume", task.Name, "--yes", "--no-wait") + inv, root := clitest.New(t, "task", "resume", setup.task.Name, "--yes", "--no-wait") output := clitest.Capture(inv) - clitest.SetupConfig(t, userClient, root) + clitest.SetupConfig(t, setup.userClient, root) // Then: We expect the task to be resumed in the background ctx := testutil.Context(t, testutil.WaitMedium) @@ -99,11 +87,11 @@ func TestExpTaskResume(t *testing.T) { require.Contains(t, output.Stdout(), "in the background") // And: The task to eventually be resumed - require.True(t, task.WorkspaceID.Valid, "task should have a workspace ID") - ws := coderdtest.MustWorkspace(t, userClient, task.WorkspaceID.UUID) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, ws.LatestBuild.ID) + require.True(t, setup.task.WorkspaceID.Valid, "task should have a workspace ID") + ws := coderdtest.MustWorkspace(t, setup.userClient, setup.task.WorkspaceID.UUID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, setup.userClient, ws.LatestBuild.ID) - updated, err := userClient.TaskByIdentifier(ctx, task.Name) + updated, err := setup.userClient.TaskByIdentifier(ctx, setup.task.Name) require.NoError(t, err) require.Equal(t, codersdk.TaskStatusInitializing, updated.Status) }) @@ -113,12 +101,12 @@ func TestExpTaskResume(t *testing.T) { // Given: A paused task setupCtx := testutil.Context(t, testutil.WaitLong) - _, userClient, task := setupCLITaskTest(setupCtx, t, nil) - pauseTask(setupCtx, t, userClient, task) + setup := setupCLITaskTest(setupCtx, t, nil) + pauseTask(setupCtx, t, setup.userClient, setup.task) // When: We attempt to resume the task - inv, root := clitest.New(t, "task", "resume", task.Name) - clitest.SetupConfig(t, userClient, root) + inv, root := clitest.New(t, "task", "resume", setup.task.Name) + clitest.SetupConfig(t, setup.userClient, root) // And: We confirm we want to resume the task ctx := testutil.Context(t, testutil.WaitMedium) @@ -132,7 +120,7 @@ func TestExpTaskResume(t *testing.T) { pty.ExpectMatchContext(ctx, "has been resumed") require.NoError(t, w.Wait()) - updated, err := userClient.TaskByIdentifier(ctx, task.Name) + updated, err := setup.userClient.TaskByIdentifier(ctx, setup.task.Name) require.NoError(t, err) require.Equal(t, codersdk.TaskStatusInitializing, updated.Status) }) @@ -142,12 +130,12 @@ func TestExpTaskResume(t *testing.T) { // Given: A paused task setupCtx := testutil.Context(t, testutil.WaitLong) - _, userClient, task := setupCLITaskTest(setupCtx, t, nil) - pauseTask(setupCtx, t, userClient, task) + setup := setupCLITaskTest(setupCtx, t, nil) + pauseTask(setupCtx, t, setup.userClient, setup.task) // When: We attempt to resume the task - inv, root := clitest.New(t, "task", "resume", task.Name) - clitest.SetupConfig(t, userClient, root) + inv, root := clitest.New(t, "task", "resume", setup.task.Name) + clitest.SetupConfig(t, setup.userClient, root) // But: Say no at the confirmation screen ctx := testutil.Context(t, testutil.WaitMedium) @@ -159,7 +147,7 @@ func TestExpTaskResume(t *testing.T) { require.Error(t, w.Wait()) // Then: We expect the task to still be paused - updated, err := userClient.TaskByIdentifier(ctx, task.Name) + updated, err := setup.userClient.TaskByIdentifier(ctx, setup.task.Name) require.NoError(t, err) require.Equal(t, codersdk.TaskStatusPaused, updated.Status) }) @@ -169,11 +157,11 @@ func TestExpTaskResume(t *testing.T) { // Given: A running task setupCtx := testutil.Context(t, testutil.WaitLong) - _, userClient, task := setupCLITaskTest(setupCtx, t, nil) + setup := setupCLITaskTest(setupCtx, t, nil) // When: We attempt to resume the task that is not paused - inv, root := clitest.New(t, "task", "resume", task.Name, "--yes") - clitest.SetupConfig(t, userClient, root) + inv, root := clitest.New(t, "task", "resume", setup.task.Name, "--yes") + clitest.SetupConfig(t, setup.userClient, root) // Then: We expect to get an error that the task is not paused ctx := testutil.Context(t, testutil.WaitMedium) diff --git a/cli/task_send.go b/cli/task_send.go index 97f1555a83..550b2708c4 100644 --- a/cli/task_send.go +++ b/cli/task_send.go @@ -1,10 +1,15 @@ package cli import ( + "context" + "fmt" "io" + "time" + "github.com/google/uuid" "golang.org/x/xerrors" + "github.com/coder/coder/v2/cli/cliui" "github.com/coder/coder/v2/codersdk" "github.com/coder/serpent" ) @@ -15,13 +20,15 @@ func (r *RootCmd) taskSend() *serpent.Command { cmd := &serpent.Command{ Use: "send [ | --stdin]", Short: "Send input to a task", - Long: FormatExamples(Example{ - Description: "Send direct input to a task.", - Command: "coder task send task1 \"Please also add unit tests\"", - }, Example{ - Description: "Send input from stdin to a task.", - Command: "echo \"Please also add unit tests\" | coder task send task1 --stdin", - }), + Long: `Send input to a task. If the task is paused, it will be automatically resumed before input is sent. If the task is initializing, it will wait for the task to become ready. +` + + FormatExamples(Example{ + Description: "Send direct input to a task", + Command: `coder task send task1 "Please also add unit tests"`, + }, Example{ + Description: "Send input from stdin to a task", + Command: `echo "Please also add unit tests" | coder task send task1 --stdin`, + }), Middleware: serpent.RequireRangeArgs(1, 2), Options: serpent.OptionSet{ { @@ -64,8 +71,48 @@ func (r *RootCmd) taskSend() *serpent.Command { return xerrors.Errorf("resolve task: %w", err) } - if err = client.TaskSend(ctx, codersdk.Me, task.ID, codersdk.TaskSendRequest{Input: taskInput}); err != nil { - return xerrors.Errorf("send input to task: %w", err) + display := fmt.Sprintf("%s/%s", task.OwnerName, task.Name) + + // Before attempting to send, check the task status and + // handle non-active states. + var workspaceBuildID uuid.UUID + + switch task.Status { + case codersdk.TaskStatusActive: + // Already active, no build to watch. + + case codersdk.TaskStatusPaused: + resp, err := client.ResumeTask(ctx, task.OwnerName, task.ID) + if err != nil { + return xerrors.Errorf("resume task %q: %w", display, err) + } else if resp.WorkspaceBuild == nil { + return xerrors.Errorf("resume task %q", display) + } + + workspaceBuildID = resp.WorkspaceBuild.ID + + case codersdk.TaskStatusInitializing: + if !task.WorkspaceID.Valid { + return xerrors.Errorf("send input to task %q: task has no backing workspace", display) + } + + workspace, err := client.Workspace(ctx, task.WorkspaceID.UUID) + if err != nil { + return xerrors.Errorf("get workspace for task %q: %w", display, err) + } + + workspaceBuildID = workspace.LatestBuild.ID + + default: + return xerrors.Errorf("task %q has status %s and cannot be sent input", display, task.Status) + } + + if err := waitForTaskIdle(ctx, inv, client, task, workspaceBuildID); err != nil { + return xerrors.Errorf("wait for task %q to be idle: %w", display, err) + } + + if err := client.TaskSend(ctx, codersdk.Me, task.ID, codersdk.TaskSendRequest{Input: taskInput}); err != nil { + return xerrors.Errorf("send input to task %q: %w", display, err) } return nil @@ -74,3 +121,101 @@ func (r *RootCmd) taskSend() *serpent.Command { return cmd } + +// waitForTaskIdle optionally watches a workspace build to completion, +// then polls until the task becomes active and its app state is idle. +// This merges build-watching and idle-polling into a single loop so +// that status changes (e.g. paused) are never missed between phases. +func waitForTaskIdle(ctx context.Context, inv *serpent.Invocation, client *codersdk.Client, task codersdk.Task, workspaceBuildID uuid.UUID) error { + if workspaceBuildID != uuid.Nil { + if err := cliui.WorkspaceBuild(ctx, inv.Stdout, client, workspaceBuildID); err != nil { + return xerrors.Errorf("watch workspace build: %w", err) + } + } + + cliui.Infof(inv.Stdout, "Waiting for task to become idle...") + + // NOTE(DanielleMaywood): + // It has been observed that the `TaskStatusError` state has + // appeared during a typical healthy startup [^0]. To combat + // this, we allow a 5 minute grace period where we allow + // `TaskStatusError` to surface without immediately failing. + // + // TODO(DanielleMaywood): + // Remove this grace period once the upstream agentapi health + // check no longer reports transient error states during normal + // startup. + // + // [0]: https://github.com/coder/coder/pull/22203#discussion_r2858002569 + const errorGracePeriod = 5 * time.Minute + gracePeriodDeadline := time.Now().Add(errorGracePeriod) + + // NOTE(DanielleMaywood): + // On resume the MCP may not report an initial app status, + // leaving CurrentState nil indefinitely. To avoid hanging + // forever we treat Active with nil CurrentState as idle + // after a grace period, giving the MCP time to report + // during normal startup. + const nilStateGracePeriod = 30 * time.Second + var nilStateDeadline time.Time + + // TODO(DanielleMaywood): + // When we have a streaming Task API, this should be converted + // away from polling. + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + task, err := client.TaskByID(ctx, task.ID) + if err != nil { + return xerrors.Errorf("get task by id: %w", err) + } + + switch task.Status { + case codersdk.TaskStatusInitializing, + codersdk.TaskStatusPending: + // Not yet active, keep polling. + continue + case codersdk.TaskStatusActive: + // Task is active; check app state. + if task.CurrentState == nil { + // The MCP may not have reported state yet. + // Start a grace period on first observation + // and treat as idle once it expires. + if nilStateDeadline.IsZero() { + nilStateDeadline = time.Now().Add(nilStateGracePeriod) + } + if time.Now().After(nilStateDeadline) { + return nil + } + continue + } + // Reset nil-state deadline since we got a real + // state report. + nilStateDeadline = time.Time{} + switch task.CurrentState.State { + case codersdk.TaskStateIdle, + codersdk.TaskStateComplete, + codersdk.TaskStateFailed: + return nil + default: + // Still working, keep polling. + continue + } + case codersdk.TaskStatusError: + if time.Now().After(gracePeriodDeadline) { + return xerrors.Errorf("task entered %s state while waiting for it to become idle", task.Status) + } + case codersdk.TaskStatusPaused: + return xerrors.Errorf("task was paused while waiting for it to become idle") + case codersdk.TaskStatusUnknown: + return xerrors.Errorf("task entered %s state while waiting for it to become idle", task.Status) + default: + return xerrors.Errorf("task entered unexpected state (%s) while waiting for it to become idle", task.Status) + } + } + } +} diff --git a/cli/task_send_test.go b/cli/task_send_test.go index 0aa6713404..10d405de64 100644 --- a/cli/task_send_test.go +++ b/cli/task_send_test.go @@ -12,9 +12,14 @@ import ( "github.com/stretchr/testify/require" agentapisdk "github.com/coder/agentapi-sdk-go" + "github.com/coder/coder/v2/agent" + "github.com/coder/coder/v2/agent/agenttest" "github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" + "github.com/coder/coder/v2/pty/ptytest" "github.com/coder/coder/v2/testutil" ) @@ -25,12 +30,12 @@ func Test_TaskSend(t *testing.T) { t.Parallel() setupCtx := testutil.Context(t, testutil.WaitLong) - _, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it")) + setup := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it")) var stdout strings.Builder - inv, root := clitest.New(t, "task", "send", task.Name, "carry on with the task") + inv, root := clitest.New(t, "task", "send", setup.task.Name, "carry on with the task") inv.Stdout = &stdout - clitest.SetupConfig(t, userClient, root) + clitest.SetupConfig(t, setup.userClient, root) ctx := testutil.Context(t, testutil.WaitLong) err := inv.WithContext(ctx).Run() @@ -41,12 +46,12 @@ func Test_TaskSend(t *testing.T) { t.Parallel() setupCtx := testutil.Context(t, testutil.WaitLong) - _, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it")) + setup := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it")) var stdout strings.Builder - inv, root := clitest.New(t, "task", "send", task.ID.String(), "carry on with the task") + inv, root := clitest.New(t, "task", "send", setup.task.ID.String(), "carry on with the task") inv.Stdout = &stdout - clitest.SetupConfig(t, userClient, root) + clitest.SetupConfig(t, setup.userClient, root) ctx := testutil.Context(t, testutil.WaitLong) err := inv.WithContext(ctx).Run() @@ -57,13 +62,13 @@ func Test_TaskSend(t *testing.T) { t.Parallel() setupCtx := testutil.Context(t, testutil.WaitLong) - _, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it")) + setup := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it")) var stdout strings.Builder - inv, root := clitest.New(t, "task", "send", task.Name, "--stdin") + inv, root := clitest.New(t, "task", "send", setup.task.Name, "--stdin") inv.Stdout = &stdout inv.Stdin = strings.NewReader("carry on with the task") - clitest.SetupConfig(t, userClient, root) + clitest.SetupConfig(t, setup.userClient, root) ctx := testutil.Context(t, testutil.WaitLong) err := inv.WithContext(ctx).Run() @@ -110,17 +115,223 @@ func Test_TaskSend(t *testing.T) { t.Parallel() setupCtx := testutil.Context(t, testutil.WaitLong) - _, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendErr(t, assert.AnError)) + setup := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendErr(assert.AnError)) var stdout strings.Builder - inv, root := clitest.New(t, "task", "send", task.Name, "some task input") + inv, root := clitest.New(t, "task", "send", setup.task.Name, "some task input") inv.Stdout = &stdout - clitest.SetupConfig(t, userClient, root) + clitest.SetupConfig(t, setup.userClient, root) ctx := testutil.Context(t, testutil.WaitLong) err := inv.WithContext(ctx).Run() require.ErrorContains(t, err, assert.AnError.Error()) }) + + t.Run("WaitsForInitializingTask", func(t *testing.T) { + t.Parallel() + + setupCtx := testutil.Context(t, testutil.WaitLong) + setup := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "some task input", "some task response")) + + // Close the first agent, pause, then resume the task so the + // workspace is started but no agent is connected. + // This puts the task in "initializing" state. + require.NoError(t, setup.agent.Close()) + pauseTask(setupCtx, t, setup.userClient, setup.task) + resumeTask(setupCtx, t, setup.userClient, setup.task) + + // When: We attempt to send input to the initializing task. + inv, root := clitest.New(t, "task", "send", setup.task.Name, "some task input") + clitest.SetupConfig(t, setup.userClient, root) + + ctx := testutil.Context(t, testutil.WaitLong) + inv = inv.WithContext(ctx) + + // Use a pty so we can wait for the command to produce build + // output, confirming it has entered the initializing code + // path before we connect the agent. + pty := ptytest.New(t).Attach(inv) + w := clitest.StartWithWaiter(t, inv) + + // Wait for the command to observe the initializing state and + // start watching the workspace build. This ensures the command + // has entered the waiting code path. + pty.ExpectMatchContext(ctx, "Queued") + + // Connect a new agent so the task can transition to active. + agentClient := agentsdk.New(setup.userClient.URL, agentsdk.WithFixedToken(setup.agentToken)) + setup.agent = agenttest.New(t, setup.userClient.URL, setup.agentToken, func(o *agent.Options) { + o.Client = agentClient + }) + coderdtest.NewWorkspaceAgentWaiter(t, setup.userClient, setup.task.WorkspaceID.UUID). + WaitFor(coderdtest.AgentsReady) + + // Report the task app as idle so waitForTaskIdle can proceed. + require.NoError(t, agentClient.PatchAppStatus(ctx, agentsdk.PatchAppStatus{ + AppSlug: "task-sidebar", + State: codersdk.WorkspaceAppStatusStateIdle, + Message: "ready", + })) + + // Then: The command should complete successfully. + require.NoError(t, w.Wait()) + + updated, err := setup.userClient.TaskByIdentifier(ctx, setup.task.Name) + require.NoError(t, err) + require.Equal(t, codersdk.TaskStatusActive, updated.Status) + }) + + t.Run("ResumesPausedTask", func(t *testing.T) { + t.Parallel() + + setupCtx := testutil.Context(t, testutil.WaitLong) + setup := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "some task input", "some task response")) + + // Close the first agent before pausing so it does not conflict + // with the agent we reconnect after the workspace is resumed. + require.NoError(t, setup.agent.Close()) + pauseTask(setupCtx, t, setup.userClient, setup.task) + + // When: We attempt to send input to the paused task. + inv, root := clitest.New(t, "task", "send", setup.task.Name, "some task input") + clitest.SetupConfig(t, setup.userClient, root) + + ctx := testutil.Context(t, testutil.WaitLong) + inv = inv.WithContext(ctx) + + // Use a pty so we can wait for the command to produce build + // output, confirming it has entered the paused code path and + // triggered a resume before we connect the agent. + pty := ptytest.New(t).Attach(inv) + w := clitest.StartWithWaiter(t, inv) + + // Wait for the command to observe the paused state, trigger + // a resume, and start watching the workspace build. + pty.ExpectMatchContext(ctx, "Queued") + + // Connect a new agent so the task can transition to active. + agentClient := agentsdk.New(setup.userClient.URL, agentsdk.WithFixedToken(setup.agentToken)) + setup.agent = agenttest.New(t, setup.userClient.URL, setup.agentToken, func(o *agent.Options) { + o.Client = agentClient + }) + coderdtest.NewWorkspaceAgentWaiter(t, setup.userClient, setup.task.WorkspaceID.UUID). + WaitFor(coderdtest.AgentsReady) + + // Report the task app as idle so waitForTaskIdle can proceed. + require.NoError(t, agentClient.PatchAppStatus(ctx, agentsdk.PatchAppStatus{ + AppSlug: "task-sidebar", + State: codersdk.WorkspaceAppStatusStateIdle, + Message: "ready", + })) + + // Then: The command should complete successfully. + require.NoError(t, w.Wait()) + + updated, err := setup.userClient.TaskByIdentifier(ctx, setup.task.Name) + require.NoError(t, err) + require.Equal(t, codersdk.TaskStatusActive, updated.Status) + }) + + t.Run("PausedDuringWaitForReady", func(t *testing.T) { + t.Parallel() + + // Given: An initializing task (workspace running, no agent + // connected). + setupCtx := testutil.Context(t, testutil.WaitLong) + setup := setupCLITaskTest(setupCtx, t, nil) + + require.NoError(t, setup.agent.Close()) + pauseTask(setupCtx, t, setup.userClient, setup.task) + resumeTask(setupCtx, t, setup.userClient, setup.task) + + // When: We attempt to send input to the initializing task. + inv, root := clitest.New(t, "task", "send", setup.task.Name, "some task input") + clitest.SetupConfig(t, setup.userClient, root) + + ctx := testutil.Context(t, testutil.WaitLong) + inv = inv.WithContext(ctx) + + pty := ptytest.New(t).Attach(inv) + w := clitest.StartWithWaiter(t, inv) + + // Wait for the command to enter the build-watching phase + // of waitForTaskReady. + pty.ExpectMatchContext(ctx, "Queued") + + // Pause the task while waitForTaskReady is polling. Since + // no agent is connected, the task stays initializing until + // we pause it, at which point the status becomes paused. + pauseTask(ctx, t, setup.userClient, setup.task) + + // Then: The command should fail because the task was paused. + err := w.Wait() + require.Error(t, err) + require.ErrorContains(t, err, "was paused while waiting for it to become idle") + }) + + t.Run("WaitsForWorkingAppState", func(t *testing.T) { + t.Parallel() + + // Given: An active task whose app is in "working" state. + setupCtx := testutil.Context(t, testutil.WaitLong) + setup := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "some task input", "some task response")) + + // Move the app into "working" state before running the command. + agentClient := agentsdk.New(setup.userClient.URL, agentsdk.WithFixedToken(setup.agentToken)) + require.NoError(t, agentClient.PatchAppStatus(setupCtx, agentsdk.PatchAppStatus{ + AppSlug: "task-sidebar", + State: codersdk.WorkspaceAppStatusStateWorking, + Message: "busy", + })) + + // When: We send input while the app is working. + inv, root := clitest.New(t, "task", "send", setup.task.Name, "some task input") + clitest.SetupConfig(t, setup.userClient, root) + + ctx := testutil.Context(t, testutil.WaitLong) + inv = inv.WithContext(ctx) + w := clitest.StartWithWaiter(t, inv) + + // Transition the app back to idle so waitForTaskIdle proceeds. + require.NoError(t, agentClient.PatchAppStatus(ctx, agentsdk.PatchAppStatus{ + AppSlug: "task-sidebar", + State: codersdk.WorkspaceAppStatusStateIdle, + Message: "ready", + })) + + // Then: The command should complete successfully. + require.NoError(t, w.Wait()) + }) + + t.Run("SendToNonIdleAppState", func(t *testing.T) { + t.Parallel() + + for _, appState := range []codersdk.WorkspaceAppStatusState{ + codersdk.WorkspaceAppStatusStateComplete, + codersdk.WorkspaceAppStatusStateFailure, + } { + t.Run(string(appState), func(t *testing.T) { + t.Parallel() + + setupCtx := testutil.Context(t, testutil.WaitLong) + setup := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "some input", "some response")) + + agentClient := agentsdk.New(setup.userClient.URL, agentsdk.WithFixedToken(setup.agentToken)) + require.NoError(t, agentClient.PatchAppStatus(setupCtx, agentsdk.PatchAppStatus{ + AppSlug: "task-sidebar", + State: appState, + Message: "done", + })) + + inv, root := clitest.New(t, "task", "send", setup.task.Name, "some input") + clitest.SetupConfig(t, setup.userClient, root) + + ctx := testutil.Context(t, testutil.WaitLong) + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + }) + } + }) } func fakeAgentAPITaskSendOK(t *testing.T, expectMessage, returnMessage string) map[string]http.HandlerFunc { @@ -151,7 +362,7 @@ func fakeAgentAPITaskSendOK(t *testing.T, expectMessage, returnMessage string) m } } -func fakeAgentAPITaskSendErr(t *testing.T, returnErr error) map[string]http.HandlerFunc { +func fakeAgentAPITaskSendErr(returnErr error) map[string]http.HandlerFunc { return map[string]http.HandlerFunc{ "/status": func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/cli/task_test.go b/cli/task_test.go index ccdf97ef2f..33fc3d0466 100644 --- a/cli/task_test.go +++ b/cli/task_test.go @@ -88,6 +88,13 @@ func Test_Tasks(t *testing.T) { o.Client = agentClient }) coderdtest.NewWorkspaceAgentWaiter(t, userClient, tasks[0].WorkspaceID.UUID).WithContext(ctx).WaitFor(coderdtest.AgentsReady) + // Report the task app as idle so that waitForTaskIdle + // can proceed during the "send task message" step. + require.NoError(t, agentClient.PatchAppStatus(ctx, agentsdk.PatchAppStatus{ + AppSlug: "task-sidebar", + State: codersdk.WorkspaceAppStatusStateIdle, + Message: "ready", + })) }, }, { @@ -272,10 +279,19 @@ func fakeAgentAPIEcho(ctx context.Context, t testing.TB, initMsg agentapisdk.Mes // setupCLITaskTest creates a test workspace with an AI task template and agent, // with a fake agent API configured with the provided set of handlers. // Returns the user client and workspace. -func setupCLITaskTest(ctx context.Context, t *testing.T, agentAPIHandlers map[string]http.HandlerFunc) (ownerClient *codersdk.Client, memberClient *codersdk.Client, task codersdk.Task) { +// setupCLITaskTestResult holds the return values from setupCLITaskTest. +type setupCLITaskTestResult struct { + ownerClient *codersdk.Client + userClient *codersdk.Client + task codersdk.Task + agentToken string + agent agent.Agent +} + +func setupCLITaskTest(ctx context.Context, t *testing.T, agentAPIHandlers map[string]http.HandlerFunc) setupCLITaskTestResult { t.Helper() - ownerClient = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + ownerClient := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) owner := coderdtest.CreateFirstUser(t, ownerClient) userClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) @@ -292,21 +308,56 @@ func setupCLITaskTest(ctx context.Context, t *testing.T, agentAPIHandlers map[st }) require.NoError(t, err) - // Wait for the task's underlying workspace to be built + // Wait for the task's underlying workspace to be built. require.True(t, task.WorkspaceID.Valid, "task should have a workspace ID") workspace, err := userClient.Workspace(ctx, task.WorkspaceID.UUID) require.NoError(t, err) coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, workspace.LatestBuild.ID) agentClient := agentsdk.New(userClient.URL, agentsdk.WithFixedToken(authToken)) - _ = agenttest.New(t, userClient.URL, authToken, func(o *agent.Options) { + agt := agenttest.New(t, userClient.URL, authToken, func(o *agent.Options) { o.Client = agentClient }) coderdtest.NewWorkspaceAgentWaiter(t, userClient, workspace.ID). WaitFor(coderdtest.AgentsReady) - return ownerClient, userClient, task + // Report the task app as idle so that waitForTaskIdle can proceed. + err = agentClient.PatchAppStatus(ctx, agentsdk.PatchAppStatus{ + AppSlug: "task-sidebar", + State: codersdk.WorkspaceAppStatusStateIdle, + Message: "ready", + }) + require.NoError(t, err) + + return setupCLITaskTestResult{ + ownerClient: ownerClient, + userClient: userClient, + task: task, + agentToken: authToken, + agent: agt, + } +} + +// pauseTask pauses the task and waits for the stop build to complete. +func pauseTask(ctx context.Context, t *testing.T, client *codersdk.Client, task codersdk.Task) { + t.Helper() + + pauseResp, err := client.PauseTask(ctx, task.OwnerName, task.ID) + require.NoError(t, err) + require.NotNil(t, pauseResp.WorkspaceBuild) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, pauseResp.WorkspaceBuild.ID) +} + +// resumeTask resumes the task waits for the start build to complete. The task +// will be in "initializing" state after this returns because no agent is connected. +func resumeTask(ctx context.Context, t *testing.T, client *codersdk.Client, task codersdk.Task) { + t.Helper() + + resumeResp, err := client.ResumeTask(ctx, task.OwnerName, task.ID) + require.NoError(t, err) + require.NotNil(t, resumeResp.WorkspaceBuild) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, resumeResp.WorkspaceBuild.ID) } // setupCLITaskTestWithSnapshot creates a task in the specified status with a log snapshot. diff --git a/cli/testdata/coder_task_send_--help.golden b/cli/testdata/coder_task_send_--help.golden index d0966008b4..9002ae9635 100644 --- a/cli/testdata/coder_task_send_--help.golden +++ b/cli/testdata/coder_task_send_--help.golden @@ -5,11 +5,14 @@ USAGE: Send input to a task - - Send direct input to a task.: + Send input to a task. If the task is paused, it will be automatically resumed + before input is sent. If the task is initializing, it will wait for the task + to become ready. + - Send direct input to a task: $ coder task send task1 "Please also add unit tests" - - Send input from stdin to a task.: + - Send input from stdin to a task: $ echo "Please also add unit tests" | coder task send task1 --stdin diff --git a/coderd/aitasks.go b/coderd/aitasks.go index adbc4418dd..967cf361a4 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -315,6 +315,18 @@ func taskFromDBTaskAndWorkspace(dbTask database.Task, ws codersdk.Workspace) cod } } +// appStatusStateToTaskState converts a WorkspaceAppStatusState to a +// TaskState. The two enums mostly share values but "failure" in the +// app status maps to "failed" in the public task API. +func appStatusStateToTaskState(s codersdk.WorkspaceAppStatusState) codersdk.TaskState { + switch s { + case codersdk.WorkspaceAppStatusStateFailure: + return codersdk.TaskStateFailed + default: + return codersdk.TaskState(s) + } +} + // 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. @@ -334,7 +346,7 @@ func deriveTaskCurrentState( 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), + State: appStatusStateToTaskState(ws.LatestAppStatus.State), Message: ws.LatestAppStatus.Message, URI: ws.LatestAppStatus.URI, } diff --git a/docs/reference/cli/task_send.md b/docs/reference/cli/task_send.md index 0ad847a441..914d66daaf 100644 --- a/docs/reference/cli/task_send.md +++ b/docs/reference/cli/task_send.md @@ -12,11 +12,12 @@ coder task send [flags] [ | --stdin] ## Description ```console - - Send direct input to a task.: +Send input to a task. If the task is paused, it will be automatically resumed before input is sent. If the task is initializing, it will wait for the task to become ready. + - Send direct input to a task: $ coder task send task1 "Please also add unit tests" - - Send input from stdin to a task.: + - Send input from stdin to a task: $ echo "Please also add unit tests" | coder task send task1 --stdin ```