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:
Kyle Carberry
2026-02-28 12:33:52 -05:00
committed by GitHub
parent 0ad2f9ecd7
commit a621c3cb13
11 changed files with 2315 additions and 68 deletions
+126
View File
@@ -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()