-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Expand file tree
/
Copy pathtask_send.go
More file actions
221 lines (191 loc) · 6.68 KB
/
task_send.go
File metadata and controls
221 lines (191 loc) · 6.68 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
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"
)
func (r *RootCmd) taskSend() *serpent.Command {
var stdin bool
cmd := &serpent.Command{
Use: "send <task> [<input> | --stdin]",
Short: "Send input to a task",
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{
{
Name: "stdin",
Flag: "stdin",
Description: "Reads the input from stdin.",
Value: serpent.BoolOf(&stdin),
},
},
Handler: func(inv *serpent.Invocation) error {
client, err := r.InitClient(inv)
if err != nil {
return err
}
var (
ctx = inv.Context()
identifier = inv.Args[0]
taskInput string
)
if stdin {
bytes, err := io.ReadAll(inv.Stdin)
if err != nil {
return xerrors.Errorf("reading stdio: %w", err)
}
taskInput = string(bytes)
} else {
if len(inv.Args) != 2 {
return xerrors.Errorf("expected an input for the task")
}
taskInput = inv.Args[1]
}
task, err := client.TaskByIdentifier(ctx, identifier)
if err != nil {
return xerrors.Errorf("resolve 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
},
}
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)
}
}
}
}