mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(agent): add process execution API and rewrite execute tool (#22416)
## Summary Adds a new agent-side process management HTTP API and rewrites the chat execute tool to use it instead of SSH sessions. ## What changed ### New agent/agentproc/ package - **headtail.go** — Thread-safe io.Writer with bounded memory (16KB head + 16KB tail ring buffer). Provides LLM-ready output with truncation metadata and long-line truncation at 2048 bytes. - **headtail_test.go** — 16 tests including race detector coverage for concurrent writes. - **process.go** — Manager + Process types for lifecycle management using agentexec.Execer for proper OOM/nice scores. - **api.go** — HTTP API following the agentfiles chi router pattern. 4 endpoints: start, list, output, signal. ### Agent wiring (agent/agent.go, agent/api.go) Mounts the process API at /api/v0/processes, mirroring how agentfiles is mounted. ### SDK (codersdk/workspacesdk/agentconn.go) 4 new AgentConn interface methods + 7 request/response types: - StartProcess, ListProcesses, ProcessOutput, SignalProcess ### Execute tool rewrite (coderd/chatd/chattool/execute.go) - SSH to Agent API: conn.StartProcess() + conn.ProcessOutput() polling - New parameters: workdir, run_in_background - Structured response: success, exit_code, wall_duration_ms, error, truncated, note, background_process_id - Non-interactive env vars: GIT_EDITOR=true, TERM=dumb, NO_COLOR=1, PAGER=cat, etc. - Output truncation: HeadTailBuffer caps at 32KB for LLM consumption - File-dump detection with advisory notes suggesting read_file - Default timeout: 60s to 10s - Foreground polling: 200ms intervals until exit or timeout ## Architecture State lives on the agent, surviving coderd failover and instance changes. Any coderd replica can query any agent via HTTP over tailnet.
This commit is contained in:
@@ -54,13 +54,17 @@ type AgentConn interface {
|
||||
DialContext(ctx context.Context, network string, addr string) (net.Conn, error)
|
||||
GetPeerDiagnostics() tailnet.PeerDiagnostics
|
||||
ListContainers(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error)
|
||||
ListProcesses(ctx context.Context) (ListProcessesResponse, error)
|
||||
ListeningPorts(ctx context.Context) (codersdk.WorkspaceAgentListeningPortsResponse, error)
|
||||
Netcheck(ctx context.Context) (healthsdk.AgentNetcheckReport, error)
|
||||
Ping(ctx context.Context) (time.Duration, bool, *ipnstate.PingResult, error)
|
||||
ProcessOutput(ctx context.Context, id string) (ProcessOutputResponse, error)
|
||||
PrometheusMetrics(ctx context.Context) ([]byte, error)
|
||||
ReconnectingPTY(ctx context.Context, id uuid.UUID, height uint16, width uint16, command string, initOpts ...AgentReconnectingPTYInitOption) (net.Conn, error)
|
||||
DeleteDevcontainer(ctx context.Context, devcontainerID string) error
|
||||
RecreateDevcontainer(ctx context.Context, devcontainerID string) (codersdk.Response, error)
|
||||
SignalProcess(ctx context.Context, id string, signal string) error
|
||||
StartProcess(ctx context.Context, req StartProcessRequest) (StartProcessResponse, error)
|
||||
LS(ctx context.Context, path string, req LSRequest) (LSResponse, error)
|
||||
ReadFile(ctx context.Context, path string, offset, limit int64) (io.ReadCloser, string, error)
|
||||
ReadFileLines(ctx context.Context, path string, offset, limit int64, limits ReadFileLinesLimits) (ReadFileLinesResponse, error)
|
||||
@@ -498,6 +502,61 @@ func (c *agentConn) RecreateDevcontainer(ctx context.Context, devcontainerID str
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// StartProcessRequest is the request body for starting a
|
||||
// process on the workspace agent.
|
||||
type StartProcessRequest struct {
|
||||
Command string `json:"command"`
|
||||
WorkDir string `json:"workdir,omitempty"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
Background bool `json:"background,omitempty"`
|
||||
}
|
||||
|
||||
// StartProcessResponse is returned when a process is started.
|
||||
type StartProcessResponse struct {
|
||||
ID string `json:"id"`
|
||||
Started bool `json:"started"`
|
||||
}
|
||||
|
||||
// ListProcessesResponse contains information about tracked
|
||||
// processes on the workspace agent.
|
||||
type ListProcessesResponse struct {
|
||||
Processes []ProcessInfo `json:"processes"`
|
||||
}
|
||||
|
||||
// ProcessInfo describes a tracked process on the agent.
|
||||
type ProcessInfo struct {
|
||||
ID string `json:"id"`
|
||||
Command string `json:"command"`
|
||||
WorkDir string `json:"workdir,omitempty"`
|
||||
Background bool `json:"background"`
|
||||
Running bool `json:"running"`
|
||||
ExitCode *int `json:"exit_code,omitempty"`
|
||||
StartedAt int64 `json:"started_at_unix"`
|
||||
ExitedAt *int64 `json:"exited_at_unix,omitempty"`
|
||||
}
|
||||
|
||||
// ProcessOutputResponse contains the output of a process.
|
||||
type ProcessOutputResponse struct {
|
||||
Output string `json:"output"`
|
||||
Truncated *ProcessTruncation `json:"truncated,omitempty"`
|
||||
Running bool `json:"running"`
|
||||
ExitCode *int `json:"exit_code,omitempty"`
|
||||
}
|
||||
|
||||
// ProcessTruncation describes how process output was truncated.
|
||||
type ProcessTruncation struct {
|
||||
OriginalBytes int `json:"original_bytes"`
|
||||
RetainedBytes int `json:"retained_bytes"`
|
||||
OmittedBytes int `json:"omitted_bytes"`
|
||||
Strategy string `json:"strategy"`
|
||||
}
|
||||
|
||||
// SignalProcessRequest is the request body for signaling a
|
||||
// process on the workspace agent.
|
||||
type SignalProcessRequest struct {
|
||||
Signal string `json:"signal"`
|
||||
}
|
||||
|
||||
type LSRequest struct {
|
||||
// e.g. [], ["repos", "coder"],
|
||||
Path []string `json:"path"`
|
||||
@@ -681,6 +740,73 @@ type FileEditRequest struct {
|
||||
Files []FileEdits `json:"files"`
|
||||
}
|
||||
|
||||
// StartProcess starts a new process on the workspace agent.
|
||||
func (c *agentConn) StartProcess(ctx context.Context, req StartProcessRequest) (StartProcessResponse, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
res, err := c.apiRequest(ctx, http.MethodPost, "/api/v0/processes/start", req)
|
||||
if err != nil {
|
||||
return StartProcessResponse{}, xerrors.Errorf("do request: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return StartProcessResponse{}, codersdk.ReadBodyAsError(res)
|
||||
}
|
||||
var resp StartProcessResponse
|
||||
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
||||
}
|
||||
|
||||
// ListProcesses returns information about tracked processes on the agent.
|
||||
func (c *agentConn) ListProcesses(ctx context.Context) (ListProcessesResponse, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
res, err := c.apiRequest(ctx, http.MethodGet, "/api/v0/processes/list", nil)
|
||||
if err != nil {
|
||||
return ListProcessesResponse{}, xerrors.Errorf("do request: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return ListProcessesResponse{}, codersdk.ReadBodyAsError(res)
|
||||
}
|
||||
var resp ListProcessesResponse
|
||||
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
||||
}
|
||||
|
||||
// ProcessOutput returns the output of a tracked process on the agent.
|
||||
func (c *agentConn) ProcessOutput(ctx context.Context, id string) (ProcessOutputResponse, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
res, err := c.apiRequest(ctx, http.MethodGet, "/api/v0/processes/"+id+"/output", nil)
|
||||
if err != nil {
|
||||
return ProcessOutputResponse{}, xerrors.Errorf("do request: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return ProcessOutputResponse{}, codersdk.ReadBodyAsError(res)
|
||||
}
|
||||
var resp ProcessOutputResponse
|
||||
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
||||
}
|
||||
|
||||
// SignalProcess sends a signal to a tracked process on the agent.
|
||||
func (c *agentConn) SignalProcess(ctx context.Context, id string, signal string) error {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
res, err := c.apiRequest(ctx, http.MethodPost, "/api/v0/processes/"+id+"/signal", SignalProcessRequest{Signal: signal})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("do request: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return codersdk.ReadBodyAsError(res)
|
||||
}
|
||||
var m codersdk.Response
|
||||
if err := json.NewDecoder(res.Body).Decode(&m); err != nil {
|
||||
return xerrors.Errorf("decode response body: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EditFiles performs search and replace edits on one or more files.
|
||||
func (c *agentConn) EditFiles(ctx context.Context, edits FileEditRequest) error {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
|
||||
@@ -213,6 +213,21 @@ func (mr *MockAgentConnMockRecorder) ListContainers(ctx any) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListContainers", reflect.TypeOf((*MockAgentConn)(nil).ListContainers), ctx)
|
||||
}
|
||||
|
||||
// ListProcesses mocks base method.
|
||||
func (m *MockAgentConn) ListProcesses(ctx context.Context) (workspacesdk.ListProcessesResponse, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ListProcesses", ctx)
|
||||
ret0, _ := ret[0].(workspacesdk.ListProcessesResponse)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// ListProcesses indicates an expected call of ListProcesses.
|
||||
func (mr *MockAgentConnMockRecorder) ListProcesses(ctx any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListProcesses", reflect.TypeOf((*MockAgentConn)(nil).ListProcesses), ctx)
|
||||
}
|
||||
|
||||
// ListeningPorts mocks base method.
|
||||
func (m *MockAgentConn) ListeningPorts(ctx context.Context) (codersdk.WorkspaceAgentListeningPortsResponse, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -260,6 +275,21 @@ func (mr *MockAgentConnMockRecorder) Ping(ctx any) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Ping", reflect.TypeOf((*MockAgentConn)(nil).Ping), ctx)
|
||||
}
|
||||
|
||||
// ProcessOutput mocks base method.
|
||||
func (m *MockAgentConn) ProcessOutput(ctx context.Context, id string) (workspacesdk.ProcessOutputResponse, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ProcessOutput", ctx, id)
|
||||
ret0, _ := ret[0].(workspacesdk.ProcessOutputResponse)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// ProcessOutput indicates an expected call of ProcessOutput.
|
||||
func (mr *MockAgentConnMockRecorder) ProcessOutput(ctx, id any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ProcessOutput", reflect.TypeOf((*MockAgentConn)(nil).ProcessOutput), ctx, id)
|
||||
}
|
||||
|
||||
// PrometheusMetrics mocks base method.
|
||||
func (m *MockAgentConn) PrometheusMetrics(ctx context.Context) ([]byte, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -401,6 +431,20 @@ func (mr *MockAgentConnMockRecorder) SSHOnPort(ctx, port any) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SSHOnPort", reflect.TypeOf((*MockAgentConn)(nil).SSHOnPort), ctx, port)
|
||||
}
|
||||
|
||||
// SignalProcess mocks base method.
|
||||
func (m *MockAgentConn) SignalProcess(ctx context.Context, id, signal string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "SignalProcess", ctx, id, signal)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// SignalProcess indicates an expected call of SignalProcess.
|
||||
func (mr *MockAgentConnMockRecorder) SignalProcess(ctx, id, signal any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SignalProcess", reflect.TypeOf((*MockAgentConn)(nil).SignalProcess), ctx, id, signal)
|
||||
}
|
||||
|
||||
// Speedtest mocks base method.
|
||||
func (m *MockAgentConn) Speedtest(ctx context.Context, direction speedtest.Direction, duration time.Duration) ([]speedtest.Result, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -416,6 +460,21 @@ func (mr *MockAgentConnMockRecorder) Speedtest(ctx, direction, duration any) *go
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Speedtest", reflect.TypeOf((*MockAgentConn)(nil).Speedtest), ctx, direction, duration)
|
||||
}
|
||||
|
||||
// StartProcess mocks base method.
|
||||
func (m *MockAgentConn) StartProcess(ctx context.Context, req workspacesdk.StartProcessRequest) (workspacesdk.StartProcessResponse, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "StartProcess", ctx, req)
|
||||
ret0, _ := ret[0].(workspacesdk.StartProcessResponse)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// StartProcess indicates an expected call of StartProcess.
|
||||
func (mr *MockAgentConnMockRecorder) StartProcess(ctx, req any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartProcess", reflect.TypeOf((*MockAgentConn)(nil).StartProcess), ctx, req)
|
||||
}
|
||||
|
||||
// TailnetConn mocks base method.
|
||||
func (m *MockAgentConn) TailnetConn() *tailnet.Conn {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
Reference in New Issue
Block a user