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:
Ethan
2026-05-11 16:23:07 +10:00
committed by GitHub
parent cee504e8a0
commit bd6cc1aaf2
11 changed files with 1250 additions and 114 deletions
+77 -6
View File
@@ -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{