Closes CODAGT-736
Concurrent chat model config writes on a deployment with no default all
elect themselves default: at READ COMMITTED neither transaction sees the
other's uncommitted default, so both self-promote and
`idx_chat_model_configs_single_default` rejects the loser as a spurious
409. The coderd Terraform provider hits this routinely, since a single
`terraform apply` creates or deletes many configs in parallel by design.
The fix serializes the election with a transaction-scoped advisory lock:
the create, update, and delete handlers run their default election
inside a transaction that first takes `pg_advisory_xact_lock` on a
dedicated `LockIDChatModelConfigDefault`, so elections run one at a time
and the index is never contended. The partial unique index stays in
place as the schema-level invariant, and the existing 409 mapping
remains as a backstop for any writer that bypasses the lock.
We considered a singleton pointer table (one row holding a
`model_config_id` FK, making a second default unrepresentable), which
would remove the race outright, but it needs a migration, new queries,
dbauthz rules, and handler/read-path rework. Not proportionate for an
experimental endpoint.
> 🤖 This PR was written by Coder Agents on behalf of Jake Howell.
Closes
[DEVEX-381](https://linear.app/codercom/issue/DEVEX-381/flake-test-tasksendwaitsforworkingappstate).
Follow-up to #25648 and #25858, which addressed a different symptom of
the same test.
## Symptom
```
task_send_test.go:348: context expired while waiting for trap: context deadline exceeded
--- FAIL: Test_TaskSend/WaitsForWorkingAppState (26.02s)
```
Windows-only, on `test-go-pg (windows-2022)`. Reported four times since
#25648 landed (2026-06-02, 2026-06-10, 2026-07-01).
## Root cause
The test:
1. `setupCLITaskTest` inserts `workspace_app_status(state=idle)` at the
end of setup.
2. `WaitsForWorkingAppState` then inserts
`workspace_app_status(state=working)` before starting the CLI.
3. Both are persisted via `dbtime.Now()`, which rounds to microseconds.
Windows `time.Now()` resolution is coarser than that (often ~1 ms or
worse), so back-to-back calls frequently round to the same microsecond.
4. `GetLatestWorkspaceAppStatusesByWorkspaceIDs` has no tiebreaker:
```sql
ORDER BY workspace_id, created_at DESC
```
Its sibling `GetLatestWorkspaceAppStatusByAppID` already uses `ORDER BY
created_at DESC, id DESC` for exactly this reason. When the two rows
collide, Postgres picks either.
5. On the failing runs, the query returned the `idle` row.
`waitForTaskIdle` saw idle on the first poll, returned nil, `TaskSend`
proceeded, and the CLI completed successfully in ~5 s.
6. But the test was blocked at `resetTrap.MustWait(ctx)` waiting for a
**second** `ticker.Reset` that never happened. `WaitLong = 25s` elapsed,
line 348 failed.
CI log confirms the sequence: only one `Ticker.Reset(5s)` is caught,
then `Ticker.Stop([]) call, matched 0 traps` (from `defer
ticker.Stop()`), then the trap wait times out.
This is the same class of flake Spike documented in #15923 and #21332
("Windows in particular doesn't have high-resolution timers"), just
hidden behind a SQL `ORDER BY`.
## Fix
Two changes:
1. **`coderd/database/queries/workspaceapps.sql`**: add an `id DESC`
tiebreaker to `GetLatestWorkspaceAppStatusesByWorkspaceIDs`, matching
`GetLatestWorkspaceAppStatusByAppID`. Makes the query deterministic when
`created_at` collides.
2. **`cli/task_test.go` / `cli/task_send_test.go`**: add a
`withoutInitialAppStatus()` option to `setupCLITaskTest` and use it from
`WaitsForWorkingAppState`. The test now inserts a single `working` row,
so the collision cannot happen in the first place. Belt-and-braces with
change 1.
Comments in both places reference DEVEX-381 and #21332 so the next agent
doesn't have to re-derive this.
## Verification
- `go test ./cli -run 'Test_TaskSend' -count=1`: all 12 subtests pass,
`WaitsForWorkingAppState` completes in ~5.6 s (was ~16 s previously due
to a longer poll loop).
- Stress: 20 sequential runs of `WaitsForWorkingAppState` on Linux,
race-enabled binary, all pass in ~5.5 s each.
- `go test ./coderd -run 'AppStatus|Task' -count=1` passes.
- `go vet ./coderd/database/... ./cli/...` clean.
- `make lint/emdash` clean.
- `gofmt` clean.
Not reproducible on Linux (real time between the two patches is orders
of magnitude larger than microsecond); the Windows path is fixed by
making the ordering deterministic and by not creating the collision in
the first place.
<details>
<summary>Implementation plan & decision log</summary>
### Investigation
1. Pulled the failing job log for run `28483879823/job/84428355669`.
2. Traced the mock-clock trap sequence: one `NewTicker` and exactly one
`Ticker.Reset(5s)` were caught, then `Ticker.Stop([]) call, matched 0
traps` fires (the `defer ticker.Stop()` on `waitForTaskIdle` return).
This proves `waitForTaskIdle` returned after a single poll, not that the
trap machinery hung.
3. The command exited with `<nil>` (`clitest.go:299: command "coder task
send" exited with error: <nil>`) and a `POST /send` completed in 5.4 s.
So the CLI succeeded; the test's own trap wait is what timed out.
4. The only `waitForTaskIdle` return-nil paths are `Active +
CurrentState.State in {Idle, Complete, Failed}` and `Active +
CurrentState == nil past 30s grace`. First observation of nil cannot be
past 30s. So `TaskByID` must have returned `State == Idle`.
5. Traced `TaskByID` → `taskGet` → `workspaceData` →
`GetLatestWorkspaceAppStatusesByWorkspaceIDs`. Found the missing
tiebreaker; the sibling query one line above
(`GetLatestWorkspaceAppStatusByAppID`) already had it.
6. Confirmed the two `PATCH /app-status` calls in the Windows log
happened at `00:26:13.077` and `00:26:13.093`, well within Windows timer
resolution.
7. Confirmed `dbtime.Now()` rounds to microseconds; Windows `time.Now()`
doesn't have that precision, so `Round(time.Microsecond)` on two calls
close together frequently produces equal values.
### Prior art from Spike
- #15923: loosened `HeartbeatPeriod * 9/10` to `3/4` for Windows.
- #21332: switched `assert.After` to `assert.NotBefore` because
timestamps can equal on Windows.
Both explicitly cite "Windows doesn't always have high-resolution timers
available."
### Considered alternatives
- **Only fix the test.** Works today but leaves the SQL query
non-deterministic; another test that relies on
`GetLatestWorkspaceAppStatusesByWorkspaceIDs` could hit the same
collision.
- **Only fix the SQL query.** Would give a stable answer but not
necessarily the *right* one. If both patches share a `created_at`, `id
DESC` picks whichever UUID sorted higher, still random with respect to
insertion order.
- **Make `dbtime.Now()` monotonic per process.** Cleanest at the source,
but affects every timestamp in the database and has broader implications
than a targeted flake fix.
Going with both the query fix (defense in depth, matches existing
pattern) and the test fix (eliminates the collision at the source) is
the smallest change that closes the flake and hardens the query.
### Rejected commit-message scopes
Changes touch both `cli/` and `coderd/database/`, so per AGENTS.md the
scope is omitted for the cross-cutting commit and PR title.
</details>
The chatd state machine only recognizes `waiting`, `running`, `error`,
`requires_action`, and `interrupting`. Remove the unused `pending`,
`paused`, and `completed` values from the database enum, backend, SDK,
frontend, generated queries, and API docs.
Migration `000543_chat_status_remove_unused` remaps existing `pending`
rows to `running`, remaps `paused` and `completed` rows to `waiting`,
drops the obsolete `idx_chats_pending` index, and recreates
`chats_expanded` around the enum swap. It also removes the dead
`AcquireChats` query and all remaining query literals for the deleted
statuses.
**NOTE**: The enum swap can break chat queries from older replicas
during a mixed-version rollout because they still reference
`'pending'::chat_status`. Chats are experimental, so this PR accepts
that limited rollout window instead of adding a two-release expand and
contract sequence.
> This PR was authored by Mux (AI agent) on Mike's behalf.
Categorises the terminal error of a failed interception and persists it
on the interception record, then surfaces it on the AI Gateway API.
- Categorise into an enum (`bad_request`, `unauthorized`,
`rate_limited`, `overloaded`, `server_error`, `unknown`), unwrapping
the ResponseError envelope, the upstream Anthropic/OpenAI SDK errors,
and key-pool exhaustion so blocking and streaming paths agree.
- Thread the type and raw message through the recorder dRPC into the
`aibridge_interceptions` row (optional proto fields; NULL on success).
- Expose the error on the AI Gateway thread API from the root
interception.
*This PR was produced by opencode (agent) using the `anthropic/claude-opus-4-8` model, under human direction and review.*
Adds a nullable `aibridge_interception_error_type` enum and an
`error_message` column to `aibridge_interceptions`, so a failed
interception's terminal upstream error can be persisted.
Schema only: the write path and API exposure land in the stacked
backend PR.
*This PR was produced by opencode (agent) using the `anthropic/claude-opus-4-8` model, under human direction and review.*
Closes https://linear.app/codercom/issue/CODAGT-268
## Problem
The chat UI collapses large pastes (>=10 lines or >=1000 chars) into a
synthetic `pasted-text-*.txt` attachment. A chat created with only such
an attachment had no title input anywhere: the create path derived
`titleSource` only from text and file-reference parts (so the chat was
named "New Chat"), async auto-titling extracted text the same way and
silently skipped generation, and the manual propose/regenerate paths
returned an empty title for the same reason. The regular prompt path
already inlines these files for the model; only the title paths were
blind.
## Fix
Add a single title-input derivation in `chatprompt` and use it
everywhere:
- `chatprompt.TitleText` joins text and file-reference parts (unchanged
formatting), and falls back to synthetic pasted-text attachment content
(truncated to a 16 KiB title budget) when they yield nothing.
- `chatprompt.SyntheticPasteFileIDs` identifies paste attachments;
`chatprompt.FallbackTitle` consolidates the previously duplicated
`chatTitleFromMessage` / `fallbackChatTitle`.
- Chat creation captures paste blob references while validating file
parts (the file row was already loaded there) and derives `titleSource`
via `TitleText`. Only the create path derives titles; message send and
edit reuse the same validation without copying any blob data.
- `GenerateChatTitleAsync` and the manual propose/regenerate paths
resolve paste content via `titlePasteText`, which only queries when a
visible user message has no other title text, so chats with typed text
never incur a file fetch.
- Title-path paste fetches are bounded: a new
`GetChatFileDataPrefixesByIDs` query returns only a `substr` prefix
(`chatprompt.TitlePasteBytePrefix`, 64 KiB = 4 bytes x the 16 Ki-rune
title budget) so full blobs (up to 10 MiB each) never leave the database
for titling, and `chatprompt.TitlePasteText` applies the same bound to
the create path which already holds the loaded row.
Deliberate side effect: because generation-time extraction now matches
create-time `titleSource` exactly, file-reference-only chats also become
eligible for AI titles. They were previously skipped by the same
derivation mismatch.
Non-goals: no frontend changes (attachment chip UX stays as is), and
non-synthetic user-uploaded `.txt` files still yield "New Chat".
## Testing
- Unit tests for `TitleText`, `TitlePasteText`, `SyntheticPasteFileIDs`,
`FallbackTitle`, `titleInput`, `titlePasteText`, and paste-aware
`extractManualTitleTurns`.
- Real-database test for `GetChatFileDataPrefixesByIDs` (prefix shorter
and longer than stored data) plus dbauthz coverage for the new query.
- Integration tests: paste-only create gets a fallback title from the
paste content, async title generation fires with the paste content as
input, and `RegenerateChatTitle` works on a paste-only chat.
> This PR was written by [Mux](https://mux.coder.com) on Mike's behalf.
Removes the `UpdateChatMessageByID` query. Its only non-generated
reference was its own dbauthz coverage test, so it is dead code.
> Generated by Coder Agents on behalf of @johnstcn.
This models restart as durable orchestration of existing stop and
start workspace builds instead of adding a new restart transition.
Keeping restart as two existing transitions preserves the current
build/provisioner model.
The child start build is created only after the parent stop build
succeeds, rather than being inserted immediately in a pending
state. That keeps `workspace_builds` aligned with actual
provisioner-ready work and avoids introducing a second
pending-build lifecycle that the provisioner and build acquisition
paths would need to understand.
Refs: https://linear.app/codercom/issue/PLAT-143
User Admin password resets could update the target user's hashed
password but fail while revoking that user's API keys. The transaction
then rolled back and returned HTTP 500, so the password was never
changed.
Add a user-scoped API key revoker actor and use it in both password
reset flows so key revocation succeeds without broader system auth.
Refs: https://linear.app/codercom/issue/PLAT-316
## Problem
The Generate button in the chat Rename dialog (POST
`/api/experimental/chats/{chat}/title/propose`) could fail in ways
unrelated to actual concurrent title generation:
- The manual title lock returned 409 for any `pending` chat and any
`running` chat without a worker. Legacy `pending` rows are never
acquired by workers, so those chats 409'd forever. Running chats are
unowned in the normal window between message submission and worker
acquisition (indefinitely when runners are down), producing spurious
409s.
- A missing default chat model config surfaced as a generic 500, and the
dialog hid the actionable cause carried in the error detail.
## Fix
Backend (`coderd/x/chatd`, `coderd`, `coderd/database`):
- Remove the manual title lock entirely. Races between title writers are
already resolved by `recordManualTitleUsage`, which re-reads the chat
under `GetChatByIDForUpdate` and only persists the generated title when
it is unchanged since the request snapshot, so concurrent regenerates
and renames settle by last write wins. The lock only suppressed
duplicate model calls (the dialog already disables the button in flight,
and usage limits bound spend), and its synthetic `worker_id` marker was
the source of the spurious 409s. The 409 responses, the marker and
staleness handling, and the now-unused
`UpdateChatStatusPreserveUpdatedAt` query are gone.
- New `ErrNoDefaultChatModelConfig` sentinel mapped to 400 "No default
chat model config is configured." in both title endpoints, matching the
POST `/chats` precedent.
Frontend (`site`):
- The Rename dialog error alert now renders the API error detail under
the message, reading `error.response.data.detail` directly so
detail-less API errors do not show the generic developer-console hint.
- Removed the dead regenerate-title UI plumbing (`onRegenerateTitle`
outlet wiring and the `regeneratingTitleChatIds` spinner pipeline). The
Rename dialog propose flow is the only live title-generation UX; the
endpoint, codersdk methods, and the `api.ts`/`queries/chats.ts` layer
are kept for API consumers.
## Tests
- chatd internal: a strict-mock test pinning the compare-and-swap guard
(a concurrently changed title must not be clobbered by a generated one),
plus the existing persist-and-broadcast coverage without lock
transactions.
- HTTP: `PendingWithoutWorker` expects 200 for both endpoints,
`NoDefaultModelConfig` (400) subtests, a stopped-workspace propose
regression, and an `Unauthenticated` propose subtest.
- Storybook: stories asserting the API error detail renders in the
dialog alert, and that detail-less API errors and plain errors do not
leak the developer-console hint.
> Authored by Mux on Mike's behalf.
---------
Co-authored-by: Mathias Fredriksson <mafredri@gmail.com>
Removes OpenAI Responses "chain mode" from chatd. Closes CODAGT-445.
- Deletes `chatopenai/responses.go` (chain detection, activation, prompt filtering, response ID extraction) and its tests.
- Deletes the `ChainBroken` classification in `chaterror` and the chatloop retry bookkeeping that disabled chain mode mid-generation.
- Drops the `chain_broken` label from the `coderd_chatd_stream_retries_total` metric.
- Stops reading and writing `chat_messages.provider_response_id`
- Deletes the dead `ClearChatMessageProviderResponseIDsByChatID` query. Dropping the column is a follow-up migration.
- Deletes three chatloop hooks no caller sets (`ReloadMessages`, `DisableChainMode`, `PrepareMessages`), the dead `const AgentChatContextSentinelPath`, and stale chain-mode comments.
🤖 Generated by Coder Agents on behalf of @johnstcn.
## Summary
Plumbs the Responses output item id (added as `ToolUsageRecord.ItemID` in #26855) through to the database, captured independently of the `provider_tool_call_id` correlation key. Hosted tools (`web_search_call`, etc.) only have an item id; agentic tools have both.
`provider_item_id` is specific to the OpenAI Responses API; it stays empty for chat completions and Anthropic messages, which have no separate item id.
## Changes
- Migration `000534`: nullable `provider_item_id` column on `aibridge_tool_usages`.
- Proto: `item_id` field 11 on `RecordToolUsageRequest`.
- Server handler: persists `provider_item_id` and adds it to structured logging.
- Translator: maps `ToolUsageRecord.ItemID` to the proto field.
## Tests
- `TestRecordToolUsageProviderItemID`: real-database round-trip asserting `provider_item_id` persists for both hosted and agentic tools, independently of `provider_tool_call_id`.
Stacked on #26855. Linear: AIGOV-96
---
_This PR was produced by opencode (agent) using the_ _`anthropic/claude-opus-4-8`_ _model, under human direction and review._
## Description
Adds pre-request AI budget enforcement to `aibridged`. Requests are rejected with HTTP 403 when the user's aggregated spend for the current period has reached their effective limit.
## Changes
- Add `IsBudgetExceeded` RPC to `aibridgedserver`. Resolves the user's effective budget, aggregates spend over the caller-supplied `[period_start, now]` window, and returns whether the limit has been reached along with the effective limit.
- Wire the check into `aibridged`'s HTTP handler. The caller computes the period start (monthly for now) and passes it in the request.
- Reject exceeded requests with HTTP 403 Forbidden and a message directing the user to contact an administrator.
- Add `dbtime.StartOfMonth` alongside `StartOfDay` for period computation.
- Add real-DB tests covering the enforcement path: month-boundary excludes prior-period spend, and a new user override unblocks a previously-exceeded user.
Closes https://linear.app/codercom/issue/AIGOV-428/add-pre-request-budget-enforcement
> [!NOTE]
> Initially generated by Claude Opus 4.7, modified and reviewed by @ssncferreira
The provider type already lives authoritatively in ai_providers.type,
reachable on every active row through ai_provider_id, which the
chat_model_configs_ai_provider_required_when_active CHECK makes
mandatory. The stored provider string was a denormalized copy the system
kept in sync with a startup backfill and no longer needs.
Every surface now derives provider type from the linked ai_providers
row. Telemetry is the one exception: it keeps emitting provider, now
sourced from ai_providers.type via a JOIN, so the BigQuery column and the
Nexus dashboards that read it are unaffected. The experimental HTTP/SDK
response drops provider and makes ai_provider_id required, since those
endpoints return only active configs; consumers resolve provider type
from ai_provider_id and the AI providers listing.
This ships in a single release with no compatibility window: production
reads the table via SELECT *, so a pre-drop binary fails config reads the
moment the column is gone. Operators must scale to zero before upgrading,
and there is no rollback.
Closes CODAGT-599
Configuring only a GitHub Copilot provider left the Agents page stuck on
"set up a provider then add a model", even with a provider and models
configured. The catalog dropped any provider type that NormalizeProvider
did not recognize, so a Copilot-only deployment looked identical to an
empty one and never unlocked the page.
The Agents harness cannot use Copilot: it needs a per-request token only
an official Copilot client can mint, and the harness is not one. Instead
of dropping such providers, the catalog now reports them as unsupported
so the UI can explain the dead end and point elsewhere, rather than ask
for setup that already happened. The providers stay usable through the
AI Gateway proxy.
Support is derived from the provider type, not stored, so there is no
migration. codersdk.IsAgentsUnsupportedProviderType is the single source
of truth, consulted by the chatd catalog and, through the generated
AgentsUnsupportedProviderTypes list, the frontend.
The diff also carries unrelated modernization of nearby db2sdk and
chatprovider helpers (slices.SortFunc, strings.Cut, range-over-int).
Closes CODAGT-627
Refs CODAGT-256
Refs CODAGT-682
Rename user-facing "AI Bridge" strings to "AI Gateway" in deployment
config, RBAC display names, log messages, error strings, docs style
guide, and Grafana dashboard README.
Deprecated option names and descriptions (the `--aibridge-*` block) are
intentionally kept as "AI Bridge". The `Name` field cannot be renamed
because `serpent` uses it as a unique key during JSON serialization;
duplicating names causes `UnmarshalJSON` failures (e.g. in the support
bundle). Descriptions also stay as "AI Bridge" to avoid confusion
between the deprecated and primary options.
Refs https://linear.app/codercom/issue/AIGOV-226
> Generated with the assistance of Coder Agents (@ssncferreira)
Renames the `last_used_at` column to `last_heartbeat_at` in `ai_gateway_keys` table.
`ai_gateway_keys` table has not been released yet.
All references updated.
Adds DB methods`GetAIGatewayKeyIDByHashedSecret` and `UpdateAIGatewayKeyLastUsedAt`.
`GetAIGatewayKeyIDByHashedSecret` - returns AI Gateway key ID by hashed secret value.
`UpdateAIGatewayKeyLastUsedAt` - updates last used timestamp for given AI Gateway key.
Used by standalone AI Gateway for authentication and keeping track of currently used keys.
relates to GRU-69
Adds cluster_host and nats_port to replicas table, to explicitly track NATS routes in the cluster.
I decided to make the NATS support explicit and transport the port number over the replicasync so that different Coder Servers can run on different ports. This is not something customers will typically care about, but is very useful for testing, so that they can all run on localhost within one machine.
I've also gone with a design where the NATS pubsub directly tells replicasync the port number _after_ it opens the socket. This is also very useful for testing because it allows us to have the OS assign the port number at runtime, avoiding races where we fail to bind to a free port.
## Problem
#23108 made prebuild claim delivery durable: when an agent connects to
`/api/v2/workspaceagents/me/reinit?wait=true`, the handler checks
whether the workspace's first build was created by the prebuilds system
user and whether its latest build succeeded, and if so pre-seeds a
`prebuild_claimed` reinitialization event in case the original pubsub
event was missed.
The check does not verify that the latest build is the claim build, so
it keeps firing for the rest of the workspace's life. Any workspace that
was claimed from a prebuild receives a spurious "prebuild claimed"
reinit every time its agent (re)opens the `/reinit` connection: after
every agent restart, every coderd deploy or replica restart, and every
dropped SSE connection. Each one shuts the agent down and reinitializes
it, killing SSH/IDE sessions and re-running startup scripts. In our
deployment, where most workspaces are claimed from prebuilds, this
caused fleet-wide "agent disconnected" blips whenever a coderd replica
restarted, and a few workspaces whose container exits when the agent
restarts went into a restart loop every 15-60 minutes. The agent-side
dedup (`lastOwnerID` in `cli/agent.go`) only suppresses the second event
within one agent process, so every new agent process takes at least one
spurious restart.
## Fix
Only seed the reinitialization event while the latest build is the claim
build itself, determined from the build job's input
(`prebuilt_workspace_stage`), the same signal `provisionerdserver` uses
when publishing the claim event:
- Latest build is the claim build: behavior unchanged (seed when the job
succeeded, 409 when it failed permanently, wait on pubsub while it is in
progress).
- Latest build is still a prebuilds-initiated build (claim build not
created yet): fall through to the pubsub subscription, which delivers
the claim event when the claim build completes.
- Latest build is any later user-initiated build: the claim was already
handled, so return 409 and the agent stops polling, the same as a
regular workspace.
`dbfake` gains a `MarkPrebuiltWorkspaceClaim()` builder option so tests
can model claim builds' job input, and the existing `TestReinit` claim
subtests now use it. A new subtest covers the long-claimed workspace
case.
One deliberate behavior change worth calling out: if a claim build fails
and the owner retries with another start build, the handler now returns
409 for that retry build rather than seeding a reinit. This matches the
existing treatment of failed claim builds as terminal for the reinit
poller.
## Verification
- `go test ./coderd/ -run TestReinit` against Postgres 17: all subtests
pass, including the new `workspace claimed in the past gets 409` case.
- `gofmt`, `go vet`, and `golangci-lint` (v1.64.8) are clean on the
touched packages.
- The fix mirrors behavior validated by hand against an affected
deployment: for a long-claimed workspace, `/reinit?wait=true` returned
the seeded `prebuild_claimed` event on every connection before the
change and a 409 afterwards.
Note: this branch was prepared in an environment without the full local
toolchain, so the repo's pre-commit hook (`make pre-commit`) was not run
locally; relying on CI for the full gen/fmt/lint suite. Opening as a
draft mainly to report the issue and propose a fix; happy to rework it
to the maintainers' preferred approach.
---------
Co-authored-by: Sas Swart <sas.swart.cdk@gmail.com>
This PR makes the agent-pushed pinned snapshot
(`chat_context_resources`) the sole source of workspace context for
chats, completing the "Release 5" cleanup. It removes legacy mechanisms
now superseded by the snapshot that agents push over dRPC
(`PushContextState`) and refresh via `chat-context/refresh`.
Removed:
- **Live-read at turn time.** MCP tool discovery, skill live-body reads,
and the instruction/skill history fallback that dialed the workspace on
every turn.
- **Context injected as message history.** The
`persist_workspace_context` generation action and its decision-loop
guard.
- **The legacy write path.** `POST`/`DELETE
/api/v2/workspaceagents/me/experimental/chat-context`, the agentsdk
`AddChatContext`/`ClearChatContext` methods, and the CLI one-shot
writer.
- **The `chats.last_injected_context` column** and all of its plumbing
(migration `000529`, queries, `db2sdk`, `dbauthz`, audit table, and the
frontend `ContextUsageIndicator` fallback).
Subagent context inheritance no longer copies parent context messages;
children now hydrate the parent's pinned `chat_context_resources` on
create, which yields an identical pin for the same workspace and agent.
What stays (still served by the live agent connection, not the
snapshot): `read_skill_file` supporting-file reads, `read_skill`
supporting-file listing, and MCP tool execution.
> [!NOTE]
> Migration `000529` drops `chats.last_injected_context` and recreates
the `chats_expanded` view without it. The down migration restores both.
<details>
<summary>Decision log (D1-D5)</summary>
- **D1 (subagent inheritance):** Re-point inheritance from the legacy
message copy to a pinned hydrate. Children call
`hydrateChatContextOnCreate` instead of copying parent context messages.
- **D2 (`persist_workspace_context`):** Remove the generation action
entirely along with the decision-loop guard it existed to satisfy, since
context is never injected into history anymore.
- **D3 (legacy HTTP + CLI):** Remove the experimental `chat-context`
POST/DELETE endpoints, the agentsdk methods, and the CLI one-shot. The
dRPC push + `chat-context/refresh` replace them.
- **D4 (frontend fallback):** Remove the `last_injected_context`
fallback in `ContextUsageIndicator`; pinned `resources` are the sole
source.
- **D5 (sequencing):** Ship as a single PR rather than a stacked pair.
</details>
---
Coder Agents generated on behalf of @kylecarbs.
Add `agent_firewall_session_id` and `agent_firewall_sequence_number`
fields to `AIBridgeThread` in the `GET
/api/v2/aibridge/sessions/{session_id}` response. These fields link each
thread to its agent firewall confinement session so the frontend can
discover the boundary session and compute sequence ranges for
interleaving firewall events within the thread timeline.
The database columns already exist on `aibridge_interceptions`
(migration 000520) and are already selected by
`ListAIBridgeSessionThreads`. This PR surfaces them through the SDK type
and the `db2sdk` conversion.
Depends on #24814
**Naming note:** The RFC uses `boundary_session_id` /
`boundary_sequence_number`, but the codebase standardized on
`agent_firewall_*` naming in the DB migration. The API fields follow the
existing convention.
</details>
> [!NOTE]
> This PR was authored by Coder Agents.
Add a `GET /api/v2/agent-firewall/sessions/{id}/logs` endpoint that
returns agent firewall audit logs for a given session, sorted by
sequence number ascending.
The endpoint supports `seq_after` and `seq_before` (exclusive bounds)
and `limit` query parameters. This enables the frontend to fetch exactly
the firewall events that fall between two AI Bridge interceptions within
a thread, as described in FR 4 of the Boundary/Bridge correlation RFC.
Authorization reuses the `boundary_log` RBAC resource (owner and auditor
can read; members cannot). Returns 404 for unauthorized users to avoid
leaking existence information.
The endpoint is enterprise-only, gated behind `FeatureBoundary`
entitlement, matching the session endpoint from #24814.
Depends on #24814
> [!NOTE]
> This PR was authored by Coder Agents.
In our codebase we have an existing convention of using
`flag.Lookup("test.v")` instead of `testing.Testing()`. This avoids
pulling in the entire `testing` package. Another consequence: some of
our custom linters trigger upon import of the `testing` package which
can lead to unexpected linter errors.
The dormancy notification's "will be automatically deleted in X"
sentence rendered the dormancy threshold instead of the auto-delete
duration. A 30-day threshold rendered as "4 weeks" even when auto-delete
was 90 days; a 60-day threshold rendered as "1 month" with a 7-day
auto-delete. Render the countdown from the auto-delete setting, and skip
the deletion sentence entirely when auto-delete is disabled so the
notification no longer promises a deletion that will never happen.
Add a GET endpoint at `/api/v2/agent-firewall/sessions/{id}` that
returns agent firewall session metadata (`id`, `workspace_id`,
`owner_id`, `confined_process`, `started_at`). The handler authorizes
against the `boundary_log` resource with `ActionRead` via dbauthz.
The endpoint is enterprise-only, gated behind the `FeatureBoundary`
entitlement.
The `GetBoundarySessionByID` SQL query JOINs through `workspace_agents`
→ `workspace_resources` → `workspace_builds` → `workspaces` to return
`workspace_id` and `workspace_owner_id` directly, avoiding a separate
query.
Also adds an `owner_id` column to the `boundary_logs` table (migration
000526) with a FK to `users(id)` and a backfill from
`boundary_sessions`. This enables user-scoped RBAC authorization for
`InsertBoundaryLogs` via `.WithOwner()`, ensuring workspace agents can
only insert logs for their own owner.
Depends on #24810
**RBAC behaviour:**
| Role | Result |
|---------|--------|
| Owner | read |
| Auditor | read |
| Member | 404 |
> [!NOTE]
> This PR was authored by Coder Agents.
closes CODAGT-203
## Summary
`list_templates` now returns a ranked shortlist with a recommendation,
so the chat agent can pick the right template the way a colleague would:
prefer what matches the request, what the user already uses, and what
the rest of the organization uses. Instead of teaching the model an enum
protocol in prompts, every result carries a fixed `next_step`
instruction telling the agent what to do.
## How list_templates works
1. **Fetch**: active, non-deprecated templates in the chat's
organization, filtered by the admin template allowlist, authorized as
the chat owner (no system escalation).
2. **Query relevance** (optional `query` argument): each template
receives the highest tier any of its fields matches, and a higher tier
always outranks a lower one regardless of usage:
| Tier | Match |
|------|-------|
| 4 | name or display name equals the query |
| 3 | name or display name starts with the query |
| 2 | name or display name contains the query |
| 1 | description contains the query (checked only when no name field
matched) |
| 0 | no match; the template is excluded |
Matching is case-insensitive and ignores spaces/hyphens/underscores
(`python gpu` matches `python-gpu`).
3. **Usage signals**: a new `GetTemplateRankingSignalsByOwnerID` query
returns, per template, the owner's active and recently-deleted workspace
counts within a 60-day window, the last in-window usage, and the count
of distinct developers with an active workspace (unclaimed prebuilds
excluded).
4. **Affinity score** (computed in Go, per template, from that
template's signals only):
```text
affinity = 10 x (active + 0.5 x deleted) x 0.5^(days_since_last_use /
14)
+ ln(1 + active_developers)
```
`active`/`deleted` are the owner's in-window workspace counts,
`days_since_last_use` is measured from the most recent in-window usage
(the personal term is zero without in-window usage), and
`active_developers` is the org-wide count. Personal usage carries 10x
the weight of org popularity; the confidence floor is the score of two
active developers (`ln 3`) and the required lead over the runner-up is
`ln 3 - ln 2`.
5. **Rank**: query tier first (when a query is present), then affinity
score, then name/ID for determinism. Results paginate 10 per page with
`next_page` present only when more exist.
## Recommendation contract
The result tells the agent what to do next instead of describing
confidence levels:
- `recommended_template_id` is present only when the top template is a
clear winner: the only available template, a decisive query match, or an
affinity score that clears a floor and leads the runner-up by a derived
margin.
- `next_step` is always present and is one of four fixed sentences: use
the recommendation, ask the user to choose, retry a query that matched
nothing, or report that no templates are available.
Per-template items carry raw evidence (`active_developers`,
`your_workspace_count`, `last_used_by_you`) rather than derived labels.
When signals fail to load, the tool logs and degrades to asking the user
unless the query alone is decisive.
Prompts and the `create_workspace`/`read_template` descriptions
reference the field through the `chattool.NextStepField` constant, so
the instruction lives in one place and cannot drift. `create_workspace`
remains idempotent and allowlist-enforced.
## Authorization
The signals query runs with the chat owner's permissions: reading the
owner's own workspaces plus a template-metadata read for the cross-user
popularity count. dbauthz rejects the call if any requested template is
not readable by the owner (covered by allow and deny method tests).
## Docs
Adds `docs/ai-coder/agents/tools/` explaining how agent tool calls work,
with `list_templates` ranking and the `next_step` contract as the first
documented tools.
Make `newInternalTestServer` use option functions for logger, clock, and
worker startup, and make it passive by default so internal chatd tests
only opt into background execution when they need a real worker.
Use the passive server path in `TestAwaitSubagentCompletion` for the
state-driven subtests, keep `ContextCanceled` explicitly active for real
provider cancellation coverage, and keep the fail-fast default AI
provider base URL so accidental provider calls still fail immediately.
Closes CODAGT-586
Closes https://github.com/coder/internal/issues/1549
Removes the coder agents PR Insights page (`/agents/settings/insights`) and all of its backend support. The page had previously been hidden and was only reachable via deep link. It had previously been hidden due to the dubious value provided in the current iteration.
## What
Populates `chat_context_resources` (the per-chat pinned copy added in
#26430) by copying from `workspace_agent_context_resources` at the
points where a chat's `context_aggregate_hash` is set, in the same
transaction, so the pinned hash and pinned bodies always agree. No
prompt-building change yet; consuming the pinned copy in
`prepareGeneration` is a later, experiment-gated PR.
## How
- `HydrateAgentChatsContext` now hydrates NULL-hash chats **and** copies
the agent's resources onto them in one statement (a data-modifying CTE),
so the chat-create and agent-push paths need no Go change.
- New queries `InsertAgentContextResourcesIntoChat`,
`DeleteChatContextResources`, `ListChatContextResources`, each with a
hand-written dbauthz wrapper (per-chat update/read) and a
`MethodTestSuite` entry.
- `RefreshChatContext` re-pins resources via a shared `repinChatContext`
helper (clear-then-copy in a transaction). A dirty chat keeps its old
bodies until refresh.
- On agent rebind (e.g. a workspace rebuild produces a new agent), the
chat's context is re-pinned to the new agent so it stops injecting the
previous agent's resources. Best-effort: a context error never fails the
binding.
## Invariant
A chat's `chat_context_resources` always correspond to its
`context_aggregate_hash`. Bodies are (re)written only when the hash is
set (hydrate, refresh, rebind); a dirty chat keeps its old bodies until
refresh.
## Testing
Extends the context integration test to push real resources and assert
the copy across hydrate, dirty (no re-copy), and refresh. The dbauthz
`MethodTestSuite` covers the three new methods.
<details>
<summary>Why clear-then-copy (two statements)</summary>
The refresh/rebind re-pin clears the chat's rows then inserts the
agent's. It uses two sequential statements inside the transaction rather
than a single `WITH cleared AS (DELETE ...) INSERT ...`, because a
data-modifying CTE cannot see its own delete under snapshot isolation,
so overlapping sources (the common case: the same files re-pinned) would
collide on the `(chat_id, source)` primary key. The hydrate path inserts
into never-pinned (NULL-hash) chats and uses `ON CONFLICT DO UPDATE`
defensively.
</details>
<details>
<summary>Follow-ups</summary>
- `prepareGeneration` consuming the pinned instructions and skills
(experiment-gated).
- `codersdk.ChatContext` resources plus changed diff, and the frontend
indicator/refresh.
- Removing the per-turn pull and `last_injected_context`.
</details>
---
*This PR was created by Coder Agents on behalf of @kylecarbs.* Builds on
#26430.
Adds chat_context_resources: a per-chat pinned copy of the agent context
resources a chat is hydrated against. The agent-side table
(workspace_agent_context_resources) is last-writer-wins with no history,
so a chat copies its resources at hydration/refresh to keep a stable view
while the agent drifts.
Schema foundation only (no queries/dbauthz/prepareGeneration/SDK yet).
chat_id FK ON DELETE CASCADE for cleanup parity; no agent FK so the pin
survives agent replacement; PK (chat_id, source); reuses the 000522 enum
types.
Implements
https://linear.app/codercom/issue/AIGOV-286/add-interception-cost-calculation-to-aibridge-token-usages
Adds spend attribution to AI Gateway. After the upstream response, each
token-usage record now captures the user's effective group, the
per-token prices in effect at that moment, and a computed cost — so
spend is recorded as an immutable, point-in-time snapshot.
Concretely, `aibridge_token_usages` gains `effective_group_id`,
`input_price_micros`, `output_price_micros`, `cache_read_price_micros`,
`cache_write_price_micros`, and `cost_micros`. When a usage record is
written, the effective group is resolved (per-user override, else the
deployment budget policy), the `(provider, model)` price is looked up
and snapshotted onto the row, and cost is computed from the
provider-reported token counts. A model that isn't in the price table
records its tokens with a `NULL` cost; any *other* resolution failure
fails the write, so a `NULL` cost unambiguously means "model not priced"
rather than "lookup errored."
All values are stored in micro-units (1 unit = 1,000,000 micro-units;
Phase 1 assumes USD, so 1 micro-unit = $0.000001). Prices are quoted per
million tokens.
This also grants the AI Bridge RBAC subject `read` on `ai_model_prices`
(the per-interception price lookup needs it; it previously only had
`update` for the startup seeder).
## Cost precision
Cost is computed per token category as `tokens × price / 1_000_000` with
integer division, then the four categories are summed. The division is
done **per category** (not once over the summed numerator) on purpose:
it keeps the per-category line items summing exactly to the stored total
— no "the parts don't add up to the whole" in reporting).
Integer division truncates sub-micro-unit fractions. For example, a
cheap model at $0.10 per million tokens is a price of `100_000`; 9
tokens cost `9 × 100_000 / 1_000_000 = 900_000 / 1_000_000 = 0` (the
true 0.9 micro-units floors to 0). At real list prices this rarely bites
— $3/M input is a price of `3_000_000`, so even a single token is 3
micro-units. The per-record under-count is bounded below 1 micro-unit
per category, so under $0.000004 total across the four categories, which
is acceptable for list-price-based cost approximation.
## Overflow safety
`cost_micros` is a `BIGINT` (int64), and the largest intermediate value
is a single category's `tokens × price` before division. int64's ceiling
is ≈ `9.223e18`.
- At a steep $75/M model (price `75_000_000`), overflow would require
~123 billion tokens in one response: `123e9 × 75e6 = 9.225e18`, just
over the limit. `122e9` stays under at `9.15e18`.
- A realistically maxed-out Opus 4.8 response (≈1M input + 128K output
at list prices) costs about $15, with a numerator around `1.5e13` —
roughly six orders of magnitude below the ceiling.
So overflow is unreachable from real token counts.
### Multi-currency support
In the future, we may encounter issues with multi-currency support,
especially when dealing with currencies that have very large exchange
rates relative to USD, for example:
IRR: ~1,300,000 IRR ≈ 1 USD
VND: ~26,000 VND ≈ 1 USD
For currencies with such large denominations, numeric overflow is
technically possible, considering that we have only about six orders of
magnitude of headroom before reaching the limit (see above).
## `effective_group_id` has no foreign key
`effective_group_id` records the group a spend was attributed to, as an
immutable historical fact. It is intentionally **not** a foreign key, so
the record survives deletion of the group.
Alternatives were considered and rejected:
- **`ON DELETE SET NULL`** would mutate an "immutable" record — deleting
a group silently erases that interception's attribution and under-counts
the group's historical spend.
- **`RESTRICT` / `NO ACTION`** would block group deletion entirely
(groups are hard-deleted).
- **`CASCADE`** would delete spend history when a group is deleted — the
worst outcome for an audit record.
There is also no insert-time check that the group still exists: the id
comes from a budget that was just resolved, meaning it was valid at some
point.
## Open question: group name snapshotting
Should we also snapshot the group *name* onto each record? Two options:
- **Denormalize it now** — readable in historical reports even after a
group is deleted, but the snapshot can drift from the current name on
rename, raising a "show point-in-time vs. current name" question.
- **Postpone until needed** — it's a purely additive column later, and
the name is display-only (not correctness-bearing like the price). The
cost: names of groups deleted before the column is added can't be
backfilled.
Leaning toward postponing until a concrete reporting need settles the
drift question.