From b6c258e2feaeaa6e47fc52346118d748b22e3e97 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:43:32 +0000 Subject: [PATCH 1/2] feat(coderd): lower chat stream silence timeout and make it configurable During the MCP connect-stall incident a wedged provider stream held a turn for the full 10 minute silence guard. Lower the default to 5 minutes and expose it as a hidden deployment option (--chat-stream-silence-timeout / CODER_CHAT_STREAM_SILENCE_TIMEOUT) so deployments like dogfood can tune it without a release. --- cli/testdata/server-config.yaml.golden | 4 ++++ coderd/apidoc/docs.go | 3 +++ coderd/apidoc/swagger.json | 3 +++ coderd/coderd.go | 1 + coderd/x/chatd/chatd.go | 16 ++++++++----- coderd/x/chatd/chatloop/chatloop.go | 6 +++-- coderd/x/chatd/generation.go | 1 + codersdk/deployment.go | 26 +++++++++++++++------ docs/reference/api/general.md | 3 ++- docs/reference/api/schemas.md | 31 +++++++++++++++----------- site/src/api/typesGenerated.ts | 1 + 11 files changed, 67 insertions(+), 28 deletions(-) diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 2069cb05bda..710cfd0beb7 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -805,6 +805,10 @@ chat: # opt-in settings. # (default: false, type: bool) debugLoggingEnabled: false + # How long a chat model attempt may go without receiving a stream part before it + # is canceled and retried. + # (default: 5m, type: duration) + streamSilenceTimeout: 5m0s # HTTPS URL to receive chat agent lifecycle hook events (plain HTTP requires # --chat-hook-allow-insecure). Hooks are disabled when unset. Requires the # agent-lifecycle-hooks experiment. diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 2a610074c24..8c0438b4e8e 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -17803,6 +17803,9 @@ const docTemplate = `{ }, "hook_url": { "$ref": "#/definitions/serpent.URL" + }, + "stream_silence_timeout": { + "type": "integer" } } }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index c213ab2b9c4..fecb8e5a34e 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -16017,6 +16017,9 @@ }, "hook_url": { "$ref": "#/definitions/serpent.URL" + }, + "stream_silence_timeout": { + "type": "integer" } } }, diff --git a/coderd/coderd.go b/coderd/coderd.go index ff6dfe0e845..17bfb8b0b06 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -944,6 +944,7 @@ func New(options *Options) *API { AllowBYOKSet: true, AIBridgeTransportFactory: &api.AIBridgeTransportFactory, AlwaysEnableDebugLogs: options.DeploymentValues.AI.Chat.DebugLoggingEnabled.Value(), + StreamSilenceTimeout: options.DeploymentValues.AI.Chat.StreamSilenceTimeout.Value(), Experiments: experiments, AgentConn: api.agentProvider.AgentConn, AgentInactiveDisconnectTimeout: api.AgentInactiveDisconnectTimeout, diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 665b5f99fcd..ae0c5069673 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -179,6 +179,7 @@ type Server struct { agentInactiveDisconnectTimeout time.Duration dialTimeout time.Duration instructionLookupTimeout time.Duration + streamSilenceTimeout time.Duration createWorkspaceFn chattool.CreateWorkspaceFn startWorkspaceFn chattool.StartWorkspaceFn stopWorkspaceFn chattool.StopWorkspaceFn @@ -3044,11 +3045,15 @@ type Config struct { ReplicaID uuid.UUID // StreamPartsDialer dials remote stream parts. Nil uses the local // in-process channel dialer for every stream. - StreamPartsDialer StreamPartsDialer - PendingChatAcquireInterval time.Duration - MaxChatsPerAcquire int32 - InFlightChatStaleAfter time.Duration - ChatHeartbeatInterval time.Duration + StreamPartsDialer StreamPartsDialer + PendingChatAcquireInterval time.Duration + MaxChatsPerAcquire int32 + InFlightChatStaleAfter time.Duration + ChatHeartbeatInterval time.Duration + // StreamSilenceTimeout bounds how long a model attempt may go + // without receiving a stream part before it is canceled and + // retried. Zero uses the chatloop default. + StreamSilenceTimeout time.Duration AgentConn AgentConnFunc AgentInactiveDisconnectTimeout time.Duration InstructionLookupTimeout time.Duration @@ -3146,6 +3151,7 @@ func New(ps pubsub.Pubsub, cfg Config) *Server { agentInactiveDisconnectTimeout: cfg.AgentInactiveDisconnectTimeout, dialTimeout: defaultDialTimeout, instructionLookupTimeout: instructionLookupTimeout, + streamSilenceTimeout: cfg.StreamSilenceTimeout, createWorkspaceFn: cfg.CreateWorkspace, startWorkspaceFn: cfg.StartWorkspace, stopWorkspaceFn: cfg.StopWorkspace, diff --git a/coderd/x/chatd/chatloop/chatloop.go b/coderd/x/chatd/chatloop/chatloop.go index c785277748f..890092ee3dc 100644 --- a/coderd/x/chatd/chatloop/chatloop.go +++ b/coderd/x/chatd/chatloop/chatloop.go @@ -35,8 +35,10 @@ import ( const ( // defaultStreamSilenceTimeout bounds how long an individual // model attempt may go without receiving a stream part before - // the attempt is canceled and retried. - defaultStreamSilenceTimeout = 10 * time.Minute + // the attempt is canceled and retried. Five minutes tolerates + // long provider thinking pauses while bounding how long a + // wedged provider stream can stall a turn. + defaultStreamSilenceTimeout = 5 * time.Minute streamSilenceGuardTimerTag = "streamSilenceGuard" ) diff --git a/coderd/x/chatd/generation.go b/coderd/x/chatd/generation.go index a6a5673d2dd..76c254dbd82 100644 --- a/coderd/x/chatd/generation.go +++ b/coderd/x/chatd/generation.go @@ -742,6 +742,7 @@ func (s *taskStarter) generateAssistant( ProviderOptions: prepared.ProviderOptions, PublishMessagePart: attempt.publish, OnModelStreamStart: attempt.startModelInvocation, + StreamSilenceTimeout: s.server.streamSilenceTimeout, Logger: s.opts.Logger, Clock: s.opts.Clock, Metrics: s.server.metrics, diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 5b7ea5a0610..33165ff4a61 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -4354,6 +4354,17 @@ Write out the current server config as YAML to stdout.`, Group: &deploymentGroupChat, YAML: "debugLoggingEnabled", }, + { + Name: "Chat: Stream Silence Timeout", + Description: "How long a chat model attempt may go without receiving a stream part before it is canceled and retried.", + Flag: "chat-stream-silence-timeout", + Env: "CODER_CHAT_STREAM_SILENCE_TIMEOUT", + Value: &c.AI.Chat.StreamSilenceTimeout, + Default: "5m", + Group: &deploymentGroupChat, + YAML: "streamSilenceTimeout", + Hidden: true, // Hidden because most operators should not need to modify this. + }, { Name: "Chat: Hook URL", Description: "HTTPS URL to receive chat agent lifecycle hook events (plain HTTP requires --chat-hook-allow-insecure). Hooks are disabled when unset. Requires the agent-lifecycle-hooks experiment.", @@ -5092,13 +5103,14 @@ type AIBridgeProxyConfig struct { } type ChatConfig struct { - AcquireBatchSize serpent.Int64 `json:"acquire_batch_size" typescript:",notnull"` - DebugLoggingEnabled serpent.Bool `json:"debug_logging_enabled" typescript:",notnull"` - HookURL serpent.URL `json:"hook_url" typescript:",notnull"` - HookSecret serpent.String `json:"hook_secret" typescript:",notnull"` - HookTimeout serpent.Duration `json:"hook_timeout" typescript:",notnull"` - HookEnabled serpent.Bool `json:"hook_enabled" typescript:",notnull"` - HookAllowInsecure serpent.Bool `json:"hook_allow_insecure" typescript:",notnull"` + AcquireBatchSize serpent.Int64 `json:"acquire_batch_size" typescript:",notnull"` + DebugLoggingEnabled serpent.Bool `json:"debug_logging_enabled" typescript:",notnull"` + StreamSilenceTimeout serpent.Duration `json:"stream_silence_timeout" typescript:",notnull"` + HookURL serpent.URL `json:"hook_url" typescript:",notnull"` + HookSecret serpent.String `json:"hook_secret" typescript:",notnull"` + HookTimeout serpent.Duration `json:"hook_timeout" typescript:",notnull"` + HookEnabled serpent.Bool `json:"hook_enabled" typescript:",notnull"` + HookAllowInsecure serpent.Bool `json:"hook_allow_insecure" typescript:",notnull"` // Deprecated: AI Gateway routing is now the only routing path. Setting this // value has no effect. This option will be removed in a future release. AIGatewayRoutingEnabled serpent.Bool `json:"ai_gateway_routing_enabled" typescript:",notnull" swaggerignore:"true"` diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index e11f1ef063a..b5a9a0f28ba 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -254,7 +254,8 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "rawQuery": "string", "scheme": "string", "user": {} - } + }, + "stream_silence_timeout": 0 } }, "allow_workspace_renames": true, diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 13f7b0a36f6..60c70a53e7c 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1103,7 +1103,8 @@ title: Schemas "rawQuery": "string", "scheme": "string", "user": {} - } + }, + "stream_silence_timeout": 0 } } ``` @@ -2550,21 +2551,23 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "rawQuery": "string", "scheme": "string", "user": {} - } + }, + "stream_silence_timeout": 0 } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -|-------------------------|----------------------------|----------|--------------|-------------| -| `acquire_batch_size` | integer | false | | | -| `debug_logging_enabled` | boolean | false | | | -| `hook_allow_insecure` | boolean | false | | | -| `hook_enabled` | boolean | false | | | -| `hook_secret` | string | false | | | -| `hook_timeout` | integer | false | | | -| `hook_url` | [serpent.URL](#serpenturl) | false | | | +| Name | Type | Required | Restrictions | Description | +|--------------------------|----------------------------|----------|--------------|-------------| +| `acquire_batch_size` | integer | false | | | +| `debug_logging_enabled` | boolean | false | | | +| `hook_allow_insecure` | boolean | false | | | +| `hook_enabled` | boolean | false | | | +| `hook_secret` | string | false | | | +| `hook_timeout` | integer | false | | | +| `hook_url` | [serpent.URL](#serpenturl) | false | | | +| `stream_silence_timeout` | integer | false | | | ## codersdk.ChatContext @@ -6100,7 +6103,8 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "rawQuery": "string", "scheme": "string", "user": {} - } + }, + "stream_silence_timeout": 0 } }, "allow_workspace_renames": true, @@ -6728,7 +6732,8 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "rawQuery": "string", "scheme": "string", "user": {} - } + }, + "stream_silence_timeout": 0 } }, "allow_workspace_renames": true, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 131a640455e..6237a39c175 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2091,6 +2091,7 @@ export const ChatComputerUseProviders: ChatComputerUseProvider[] = [ export interface ChatConfig { readonly acquire_batch_size: number; readonly debug_logging_enabled: boolean; + readonly stream_silence_timeout: number; readonly hook_url: string; readonly hook_secret: string; readonly hook_timeout: number; From bf71a9d2fe44e54d3e384f2c6e4a1d6946191a8f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:17:10 +0000 Subject: [PATCH 2/2] fix(codersdk): add format_duration annotation to chat stream silence timeout --- codersdk/deployment.go | 1 + 1 file changed, 1 insertion(+) diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 33165ff4a61..410e954ddc5 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -4364,6 +4364,7 @@ Write out the current server config as YAML to stdout.`, Group: &deploymentGroupChat, YAML: "streamSilenceTimeout", Hidden: true, // Hidden because most operators should not need to modify this. + Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"), }, { Name: "Chat: Hook URL",