mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(coderd): add stop_workspace chatd tool and recovery classification (#24997)
## Summary Adds a `stop_workspace` tool to chatd so the model can recover from the "workspace running but agent dead" failure mode (e.g. an OOM that leaves the workspace running but the agent unreachable) by stopping and then starting the workspace. <img width="924" height="742" alt="image" src="https://github.com/user-attachments/assets/279dedb6-6e29-4fe1-8754-3a1f01e538bf" /> ## What changed **New `stop_workspace` chatd tool** (`coderd/x/chatd/chattool/stopworkspace.go`). Mirrors `start_workspace`: shares `WorkspaceMu` to serialize with create/start, waits for any in-progress build before issuing a stop, and is idempotent only after a successful Stop transition. Failed stop builds re-attempt rather than reporting success. **New `chatStopWorkspace` coderd hook** (`coderd/exp_chats.go`). Mirrors `chatStartWorkspace` minus the `RequireActiveVersion` gate. Stop should not be blocked by template version policy. **Differentiated recovery sentinels** (`coderd/x/chatd/chatd.go`). `errChatAgentDisconnected` instructs the model to call `stop_workspace` then `start_workspace`. `errChatDialTimeout` instructs a single retry, then user escalation if it repeats. The previous single message conflated transient and persistent failures. **Two-signal recovery gate.** Recovery is only surfaced when a tool call times out *and* a fresh DB read of the latest workspace agent says `Disconnected`. The previous draft escalated on the DB read alone, which would fire on a 30-second heartbeat blip (e.g. agent respawn) and prompt a destructive stop/start unnecessarily. **Cache-hit disconnected handling** now clears the cache and retries a fresh dial before escalating, rather than returning the recovery sentinel immediately. Latest-agent classification uses `GetWorkspaceAgentsInLatestBuildByWorkspaceID` instead of the chat's bound `AgentID`, so stale bindings after a rebuild don't misclassify. **Shared chattool helpers** in `coderd/x/chatd/chattool/chattool.go`: `latestWorkspaceBuildAndJob`, `publishBuildBinding`, `provisionerJobTerminal`. Applied to both `start_workspace` and `stop_workspace`. ## Notes - Reverts an earlier draft that widened `ask_user_question` to root standard turns. Plan-mode-only behavior is restored. - The `stop_workspace` tool currently renders via the generic chat tool-call UI. A follow-up frontend PR will prettify the `stop_workspace` tool and style it like the `start_workspace` tool. - Never-connected (`Timeout` status) agents are intentionally excluded from recovery. They indicate template or startup failure, not the running-but-dead case this PR targets. Closes CODAGT-315
This commit is contained in:
+77
-6
@@ -121,6 +121,13 @@ const (
|
||||
// streamJanitorInterval.
|
||||
streamJanitorInterval = 30 * time.Second
|
||||
|
||||
// agentDisconnectedRecoveryThreshold is how long the latest
|
||||
// workspace agent must be disconnected before chatd suggests
|
||||
// destructive stop/start recovery. This is intentionally longer
|
||||
// than the inactive-disconnect timeout so short heartbeat gaps do
|
||||
// not prompt a workspace restart.
|
||||
agentDisconnectedRecoveryThreshold = 90 * time.Second
|
||||
|
||||
// DefaultMaxChatsPerAcquire is the maximum number of chats to
|
||||
// acquire in a single processOnce call. Batching avoids
|
||||
// waiting a full polling interval between acquisitions
|
||||
@@ -139,12 +146,14 @@ const (
|
||||
var (
|
||||
errChatHasNoWorkspaceAgent = xerrors.New("workspace has no running agent: the workspace is likely stopped. Use the start_workspace tool to start it")
|
||||
errChatAgentDisconnected = xerrors.New(
|
||||
"workspace agent is disconnected and cannot execute tools. " +
|
||||
"The workspace may need to be restarted from the Coder dashboard",
|
||||
"workspace agent has been disconnected for at least 90 seconds " +
|
||||
"and cannot execute tools. To recover, call stop_workspace " +
|
||||
"to stop the workspace, then start_workspace to start it " +
|
||||
"again",
|
||||
)
|
||||
errChatDialTimeout = xerrors.New(
|
||||
"connection to the workspace agent timed out. " +
|
||||
"The workspace may need to be restarted from the Coder dashboard",
|
||||
"The agent may still be reachable on the next attempt.",
|
||||
)
|
||||
errChatExternalAgentUnavailable = xerrors.New("external workspace agent unavailable")
|
||||
)
|
||||
@@ -187,6 +196,7 @@ type Server struct {
|
||||
instructionLookupTimeout time.Duration
|
||||
createWorkspaceFn chattool.CreateWorkspaceFn
|
||||
startWorkspaceFn chattool.StartWorkspaceFn
|
||||
stopWorkspaceFn chattool.StopWorkspaceFn
|
||||
pubsub pubsub.Pubsub
|
||||
webpushDispatcher webpush.Dispatcher
|
||||
providerAPIKeys chatprovider.ProviderAPIKeys
|
||||
@@ -786,6 +796,45 @@ func isAgentUnreachable(now time.Time, agent database.WorkspaceAgent, inactiveTi
|
||||
status.Status == database.WorkspaceAgentStatusTimeout
|
||||
}
|
||||
|
||||
func agentDisconnectedFor(now time.Time, agent database.WorkspaceAgent, inactiveTimeout time.Duration) (time.Duration, bool) {
|
||||
status := agent.Status(now, inactiveTimeout)
|
||||
if status.Status != database.WorkspaceAgentStatusDisconnected || status.DisconnectedAt == nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
disconnectedFor := now.Sub(*status.DisconnectedAt)
|
||||
if disconnectedFor < 0 {
|
||||
disconnectedFor = 0
|
||||
}
|
||||
return disconnectedFor, true
|
||||
}
|
||||
|
||||
func (c *turnWorkspaceContext) latestWorkspaceAgentNeedsRestart(
|
||||
ctx context.Context,
|
||||
workspaceID uuid.UUID,
|
||||
) (bool, error) {
|
||||
agentID, err := c.latestWorkspaceAgentID(ctx, workspaceID)
|
||||
if err != nil {
|
||||
if xerrors.Is(err, errChatHasNoWorkspaceAgent) {
|
||||
return false, err
|
||||
}
|
||||
c.server.logger.Warn(ctx, "failed to resolve latest agent for timeout classification", slog.Error(err))
|
||||
return false, nil
|
||||
}
|
||||
|
||||
agent, err := c.server.db.GetWorkspaceAgentByID(ctx, agentID)
|
||||
if err != nil {
|
||||
c.server.logger.Warn(ctx, "failed to load latest agent for timeout classification",
|
||||
slog.F("agent_id", agentID),
|
||||
slog.Error(err),
|
||||
)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
disconnectedFor, disconnected := agentDisconnectedFor(c.server.clock.Now(), agent, c.server.agentInactiveDisconnectTimeout)
|
||||
return disconnected && disconnectedFor >= agentDisconnectedRecoveryThreshold, nil
|
||||
}
|
||||
|
||||
func (c *turnWorkspaceContext) externalAgentError(
|
||||
ctx context.Context,
|
||||
agent database.WorkspaceAgent,
|
||||
@@ -853,9 +902,13 @@ func (c *turnWorkspaceContext) getWorkspaceConn(ctx context.Context) (workspaces
|
||||
)
|
||||
// On DB error the check re-runs on the
|
||||
// next tool call.
|
||||
} else if isAgentUnreachable(c.server.clock.Now(), freshAgent, c.server.agentInactiveDisconnectTimeout) {
|
||||
} else if _, disconnected := agentDisconnectedFor(
|
||||
c.server.clock.Now(),
|
||||
freshAgent,
|
||||
c.server.agentInactiveDisconnectTimeout,
|
||||
); disconnected {
|
||||
c.clearCachedWorkspaceState()
|
||||
return nil, c.externalAgentError(ctx, freshAgent, errChatAgentDisconnected)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return currentConn, nil
|
||||
@@ -898,6 +951,14 @@ func (c *turnWorkspaceContext) getWorkspaceConn(ctx context.Context) (workspaces
|
||||
// canceled (e.g. ErrInterrupted), its error must
|
||||
// propagate unchanged so the chatloop can detect it.
|
||||
if ctx.Err() == nil && errors.Is(context.Cause(dialCtx), errChatDialTimeout) {
|
||||
c.clearCachedWorkspaceState()
|
||||
needsRestart, statusErr := c.latestWorkspaceAgentNeedsRestart(ctx, chatSnapshot.WorkspaceID.UUID)
|
||||
if statusErr != nil {
|
||||
return nil, statusErr
|
||||
}
|
||||
if needsRestart {
|
||||
return nil, c.externalAgentError(ctx, agent, errChatAgentDisconnected)
|
||||
}
|
||||
return nil, c.externalAgentError(ctx, agent, errChatDialTimeout)
|
||||
}
|
||||
return nil, err
|
||||
@@ -3752,6 +3813,7 @@ type Config struct {
|
||||
InstructionLookupTimeout time.Duration
|
||||
CreateWorkspace chattool.CreateWorkspaceFn
|
||||
StartWorkspace chattool.StartWorkspaceFn
|
||||
StopWorkspace chattool.StopWorkspaceFn
|
||||
Pubsub pubsub.Pubsub
|
||||
ProviderAPIKeys chatprovider.ProviderAPIKeys
|
||||
AlwaysEnableDebugLogs bool
|
||||
@@ -3820,6 +3882,7 @@ func New(cfg Config) *Server {
|
||||
instructionLookupTimeout: instructionLookupTimeout,
|
||||
createWorkspaceFn: cfg.CreateWorkspace,
|
||||
startWorkspaceFn: cfg.StartWorkspace,
|
||||
stopWorkspaceFn: cfg.StopWorkspace,
|
||||
pubsub: cfg.Pubsub,
|
||||
webpushDispatcher: cfg.WebpushDispatcher,
|
||||
providerAPIKeys: cfg.ProviderAPIKeys,
|
||||
@@ -5899,7 +5962,7 @@ func builtinPlanToolAllowed(name string, isRootChat bool) bool {
|
||||
case "read_file", "execute", "process_output", "read_skill", "read_skill_file":
|
||||
return true
|
||||
case "write_file", "edit_files", "list_templates", "read_template",
|
||||
"create_workspace", "start_workspace", "propose_plan", "spawn_agent",
|
||||
"create_workspace", "start_workspace", "stop_workspace", "propose_plan", "spawn_agent",
|
||||
"spawn_explore_agent", "wait_agent", "ask_user_question":
|
||||
return isRootChat
|
||||
case "process_list", "process_signal", "message_agent", "close_agent",
|
||||
@@ -5979,6 +6042,7 @@ func allowedExploreToolNames(allTools []fantasy.AgentTool) []string {
|
||||
"read_template": false,
|
||||
"create_workspace": false,
|
||||
"start_workspace": false,
|
||||
"stop_workspace": false,
|
||||
"propose_plan": false,
|
||||
"spawn_agent": false,
|
||||
"wait_agent": false,
|
||||
@@ -6198,6 +6262,13 @@ func (p *Server) appendRootChatTools(
|
||||
OnChatUpdated: onChatUpdated,
|
||||
Logger: p.logger,
|
||||
}),
|
||||
chattool.StopWorkspace(p.db, opts.chat.ID, chattool.StopWorkspaceOptions{
|
||||
OwnerID: opts.chat.OwnerID,
|
||||
StopFn: p.stopWorkspaceFn,
|
||||
WorkspaceMu: opts.workspaceMu,
|
||||
OnChatUpdated: onChatUpdated,
|
||||
Logger: p.logger,
|
||||
}),
|
||||
)
|
||||
if opts.isPlanModeTurn {
|
||||
tools = append(tools, chattool.ProposePlan(chattool.ProposePlanOptions{
|
||||
|
||||
Reference in New Issue
Block a user