From 41260dc61546ae392a674ab2443fe40729b43395 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Wed, 9 Sep 2026 15:19:09 +0000 Subject: [PATCH] docs(coderd/x/chatd): correct timing, route, hook, and tool details in ARCHITECTURE.md Fix statements that disagree with the current code and fill in omissions noted alongside them. Each change cites its code location. - Acquisition loop: timer is 1s and lease expiry is 5m in production, set by chatd.New from DefaultPendingChatAcquireInterval and DefaultInFlightChatStaleAfter (coderd/x/chatd/chatd.go:65-68, 2988-2996, 3120-3123). coderd/coderd.go:937-965 sets neither and no deployment flag exposes them. The acquire predicate also treats a null runner_id as unowned and checks chat_heartbeats for the current (chat_id, runner_id) (coderd/database/queries/chats.sql:2461-2470). - Heartbeat loop: interval is 30s from DefaultChatHeartbeatInterval (chatd.go:83, 3122); a lease survives ten intervals after the last successful write against the 5m threshold (strict comparison at chats.sql:2469). Cleanup runs every 30s (options.go:29, runner_manager.go:499) and uses the same parameterized threshold (chats.sql:2503-2505). - HTTP endpoints: routes are registered once and mounted under both /api/experimental and /api/v2 (coderd/chat_routes.go:33-43, coderd/coderd.go:1394,1451); list the experimental-only routes (chat_routes.go:62-77, 128-139, 166-179) and the reserved 404 segments on each mount (chat_routes.go:52-60, 78-93). - message_part already carries history_version and generation_attempt (codersdk/chats.go:1692-1698). - Lifecycle hooks: enumerate the seven event types (codersdk/x/agenthooks/types.go:31-37); a permission on any other event is rejected as an invalid response (coderd/x/agenthooks/dispatch/dispatcher.go:590-599, 363-370). - Generation goroutine: list the subagent tools and the close_agent alias (coderd/x/chatd/subagent_catalog.go:17-18,43-50, subagent.go:41-47). - Replace the two TODO notes with prose describing openAIReasoningEffort (coderd/x/chatd/chatprovider/reasoningeffort.go:183-192) and UsesResponsesAPI (coderd/x/chatd/chatopenai/transport.go:64-78, coderd/x/chatd/chatprovider/chatprovider.go:767-771). --- coderd/x/chatd/ARCHITECTURE.md | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/coderd/x/chatd/ARCHITECTURE.md b/coderd/x/chatd/ARCHITECTURE.md index a27e61a257197..9d7eaf3d575fe 100644 --- a/coderd/x/chatd/ARCHITECTURE.md +++ b/coderd/x/chatd/ARCHITECTURE.md @@ -423,6 +423,8 @@ EXECUTE FUNCTION sync_chat_retry_state(); This section maps the public endpoints that mutate chat state to the transitions they use. +Chat routes are registered once by `registerChatAPIRoutes` and mounted under both `/api/experimental` and `/api/v2` during a compatibility window. Paths in this document are written with one prefix or the other, but every promoted route answers on both. The routes that were not promoted answer only on `/api/experimental`; at the time of writing these are the `providers` and `user-provider-configs` collections under `/chats`, the `computer-use-provider` and `advisor` routes under `/chats/config`, `GET /chats/{chat}/stream/desktop`, and `GET /chats/{chat}/debug/runs` with `GET /chats/{chat}/debug/runs/{debugRun}`. Each mount reserves the top-level `/chats/` collection paths it does not serve (`model-configs`, `providers`, and `user-provider-configs` on `/api/v2`; `models` and `model-configs` on `/api/experimental`) so they return 404 instead of matching the `{chat}` wildcard and failing UUID parsing. + ### Organization-scoped model discovery Clients discover models through `GET /api/v2/organizations/{organization}/chats/models`. The handler requires either full API token scope or chat model configuration read scope, then queries only configs in the requested organization that pass the caller's RBAC filter. @@ -643,16 +645,18 @@ Queued-message promotion revalidates the stored model with daemon authorization. The acquisition loop is a simple component that greedily acquires unowned or lease-expired chats from the database anytime it has a chance. It's driven by two triggers: -- a periodic timer that wakes up every 30 seconds. +- a periodic timer that wakes up every second. - a pubsub message on the `chat:ownership` channel. It finds suitable chats by fetching every chat that: - is in a runnable execution state, meaning one of: `R0`, `R1`, `I0`, `I1`, `A0`, `A1`; and -- doesn't have an owner, meaning `worker_id` is null, or its heartbeat is expired (older than 30 seconds). +- doesn't have an owner, meaning `worker_id` or `runner_id` is null, or there is no `chat_heartbeats` row for the current `(chat_id, runner_id)` newer than the lease expiry threshold of 5 minutes. For every matching chat, it locks it, checks if the chat still meets the aforementioned conditions, and performs the `Acquire(worker_id, runner_id)` transition on it. The `runner_id` is a random UUID generated by the acquisition loop. +Both the 1-second timer interval and the 5-minute lease expiry threshold are defaults applied by `chatd.New` (`DefaultPendingChatAcquireInterval` and `DefaultInFlightChatStaleAfter`); `coderd` does not override them and no deployment flag exposes them. + When a chat is successfully acquired, the acquisition loop requests the [Runner manager](#runner-manager) to spawn a chat runner for it. ### Load balancing @@ -716,7 +720,7 @@ CREATE INDEX chat_heartbeats_heartbeat_at_idx ON chat_heartbeats (heartbeat_at); ``` -For every runner registered with the runner manager, the heartbeat loop upserts the corresponding row in `chat_heartbeats` every 9 seconds. Rows are keyed by `(chat_id, runner_id)`. 9 seconds is chosen so that a worker must miss 3 heartbeats before its lease on the chat expires, and the acquisition loop on another replica can acquire its chat. +For every runner registered with the runner manager, the heartbeat loop upserts the corresponding row in `chat_heartbeats` every 30 seconds (`DefaultChatHeartbeatInterval`, applied by `chatd.New` the same way as the acquisition defaults). Rows are keyed by `(chat_id, runner_id)`. Against the 5-minute lease expiry threshold, a lease survives ten heartbeat intervals after the last successful heartbeat write; a worker whose heartbeat writes stop or fail for that long loses the lease, and the acquisition loop on any replica can acquire the chat under a new `runner_id`. The loop uses this query: @@ -732,11 +736,11 @@ Updating heartbeat rows does not advance `snapshot_version` and does not emit pu ### Heartbeat cleanup loop -The heartbeat cleanup loop periodically removes stale heartbeat rows: +The heartbeat cleanup loop runs every 30 seconds and removes heartbeat rows older than the lease expiry threshold used by the acquisition loop: ```sql DELETE FROM chat_heartbeats -WHERE heartbeat_at < now() - interval '30 seconds'; +WHERE heartbeat_at < NOW() - (INTERVAL '1 second' * $1::int); ``` Heartbeat rows are also removed automatically when their chat is deleted via the `chat_heartbeats.chat_id` foreign key. @@ -878,6 +882,8 @@ The generation goroutine supports: - chat compaction (automatic and manual, see [Manual compaction](#manual-compaction)) - MCP tools +- subagents (`spawn_agent`, `wait_agent`, `message_agent`, `interrupt_agent`, `list_agents`, `list_subagent_models`) + - `close_agent` is a deprecated alias that dispatches to `interrupt_agent`, so historical tool calls in chat history still resolve - file links - workspace binding - plan mode @@ -901,13 +907,13 @@ Subagent model and effort resolution follows this precedence: During generation preparation, the effective effort is resolved as the chat's `last_reasoning_effort` if set, else the config's `default`; clamped to the config's `max` on the global scale `none < minimal < low < medium < high < xhigh < max`; and passed through to the provider. The provider verifies whether the configured value is valid for that model at runtime. If the model config has no `reasoning_effort`, any user-selected value is ignored. The resolved value is injected into the provider-native options by `chatprovider.ProviderOptionsForCall`, which converts the model config and applies the effort in one step. For Anthropic, the fantasy provider converts effort into enabled budget thinking on models older than Claude 4.6, which reject adaptive thinking. -TODO: document that `applyReasoningEffort` clamps `none` and `minimal` to `low` for GPT-6 Astra (`chatopenai.IsGPT6Astra`), which rejects `none` with HTTP 400 and lists no `minimal` effort. +`applyReasoningEffort` clamps `none` and `minimal` to `low` for GPT-6 Astra and its dated snapshots (`chatopenai.IsGPT6Astra`, a case-insensitive prefix match on `gpt-6-astra`), because that model rejects `none` with HTTP 400 and lists no `minimal` effort. ##### OpenAI transport selection OpenAI models speak either the Responses API or Chat Completions. The provider SDK picks per model from a static known-model list, so a newly released model absent from that list falls back to Chat Completions. Model configs may override the choice with `openai_config.use_responses_api` inside `chat_model_configs.options`: unset keeps the known-model list, true forces Responses, false forces Chat Completions. It sits in `openai_config` rather than `provider_options.openai` because it is applied once when the client is built, while `provider_options` holds per-request parameters. -TODO: document that `chatopenai.UsesResponsesAPI` now owns the unset-override decision for both the client (`WithResponsesAPIFunc`) and `TransportFor`, and that GPT-6 Astra defaults to Responses because the pinned SDK predates it and its function calling is Responses-only. +`chatopenai.UsesResponsesAPI` owns the unset-override decision for both the client (`WithResponsesAPIFunc`) and `TransportFor`. When the override is nil it consults the SDK's known-model list, except that GPT-6 Astra defaults to Responses because its function calling is Responses-only. The transport is resolved exactly once, when the client is built, and carried on `chatprovider.Model` as a `chatopenai.Transport`. `Model` wraps the fantasy client with that resolved fact; its fields are unexported and only its constructor sets the transport, deriving it from the client, so no caller can pick a transport that disagrees with the client. `TransportInvalid` is the zero value and panics when read rather than defaulting to a wire format. A nil client yields that invalid zero value, which the construction path reports as an error. @@ -1021,9 +1027,9 @@ The `compaction_requested_at` marker is one-shot: transitions that keep an activ # Lifecycle hooks -When the `agent-lifecycle-hooks` experiment is enabled and a hook URL is configured, chatd sends events to an external consumer at key points in a conversation: session start, prompt submission, tool use, compaction, and turn completion. +When the `agent-lifecycle-hooks` experiment is enabled and a hook URL is configured, chatd sends events to an external consumer at key points in a conversation: session start, prompt submission, tool use, compaction, and turn completion. The event types are `session_start`, `user_prompt_submit`, `pre_tool_use`, `post_tool_use`, `pre_compact`, `post_compact`, and `stop`. -The consumer can observe activity, add model-only or user-visible context, replace supported prompt or tool input, and deny prompts or tool calls. Prompt submission is evaluated once when the submission is accepted, including queued messages and subagent prompts. Returned context becomes part of the conversation for its intended audience, except that context returned before a compaction guides the compaction summary instead. +The consumer can observe activity, add model-only or user-visible context, replace supported prompt or tool input, and deny prompts or tool calls. Only `user_prompt_submit` and `pre_tool_use` accept a `permission` decision or input override; a response carrying one on any other event is rejected as an invalid response. Prompt submission is evaluated once when the submission is accepted, including queued messages and subagent prompts. Returned context becomes part of the conversation for its intended audience, except that context returned before a compaction guides the compaction summary instead. Lifecycle hooks fail closed. If the consumer cannot be reached or returns an invalid response, Coder stops the triggering operation rather than continuing without the consumer's decision. Affected chats can enter an error state until the consumer recovers or hooks are disabled. @@ -1042,8 +1048,7 @@ The stream loop powers the `GET /api/experimental/chats/{chat}/stream` endpoint. The following chat stream events, delivered to the client over WebSocket, are supported: -- `message_part`: a streaming message part emitted by the chat worker. - - compared to the current implementation, these should additionally include the `history_version` and `generation_attempt` fields, so a client knows which episode a message part comes from +- `message_part`: a streaming message part emitted by the chat worker. Each carries the `history_version` and `generation_attempt` of the episode it belongs to, so a client knows which episode a message part comes from. - `message`: a committed chat message present in the database. - `status`: the chat's status. - `error`: the chat's persisted error payload.