From 0d340c6361edd30a674eb4f06d220aaded814a49 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:04:02 +0000 Subject: [PATCH 1/7] fix: allow manual chat compaction from the error state A chat that errors on context overflow is stuck: compaction is rejected outside the waiting state, and sending a new message re-runs the same oversized prompt. Allow RequestCompaction from E0/E1 and clear last_error on the request so /compact becomes the recovery path. --- coderd/apidoc/docs.go | 2 +- coderd/apidoc/swagger.json | 2 +- coderd/exp_chats.go | 12 ++-- coderd/exp_chats_test.go | 30 ++++++++++ coderd/x/chatd/ARCHITECTURE.md | 12 ++-- coderd/x/chatd/chatd.go | 14 ++--- coderd/x/chatd/chatd_test.go | 59 +++++++++++++++++++ .../chatstate/request_compaction_test.go | 6 +- coderd/x/chatd/chatstate/transition.go | 8 ++- coderd/x/chatd/chatstate/transitions.go | 13 ++-- .../chatstate/transitions_matrix_test.go | 16 +++-- codersdk/chats.go | 8 +-- docs/ai-coder/agents/architecture.md | 5 +- site/src/api/api.ts | 6 +- 14 files changed, 146 insertions(+), 47 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 526fe4b48fa..e0ae69762b7 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -507,7 +507,7 @@ const docTemplate = `{ }, "/api/experimental/chats/{chat}/compact": { "post": { - "description": "Experimental: this endpoint is subject to change.\nRequests a manual context compaction on an idle chat. The\ncompaction runs asynchronously through the chat worker and\nbypasses the automatic usage threshold.", + "description": "Experimental: this endpoint is subject to change.\nRequests a manual context compaction on an idle or errored\nchat, clearing any stored error. The compaction runs\nasynchronously through the chat worker and bypasses the\nautomatic usage threshold.", "produces": [ "application/json" ], diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index a009b6e7086..63b4a75fcf4 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -444,7 +444,7 @@ }, "/api/experimental/chats/{chat}/compact": { "post": { - "description": "Experimental: this endpoint is subject to change.\nRequests a manual context compaction on an idle chat. The\ncompaction runs asynchronously through the chat worker and\nbypasses the automatic usage threshold.", + "description": "Experimental: this endpoint is subject to change.\nRequests a manual context compaction on an idle or errored\nchat, clearing any stored error. The compaction runs\nasynchronously through the chat worker and bypasses the\nautomatic usage threshold.", "produces": ["application/json"], "tags": ["Chats"], "summary": "Compact chat", diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 13897ba2b43..b7d4925ea79 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -3455,9 +3455,10 @@ func (api *API) interruptChat(rw http.ResponseWriter, r *http.Request) { // @Router /api/experimental/chats/{chat}/compact [post] // @x-apidocgen {"skip": true} // @Description Experimental: this endpoint is subject to change. -// @Description Requests a manual context compaction on an idle chat. The -// @Description compaction runs asynchronously through the chat worker and -// @Description bypasses the automatic usage threshold. +// @Description Requests a manual context compaction on an idle or errored +// @Description chat, clearing any stored error. The compaction runs +// @Description asynchronously through the chat worker and bypasses the +// @Description automatic usage threshold. func (api *API) compactChat(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) @@ -3498,12 +3499,9 @@ func (api *API) compactChat(rw http.ResponseWriter, r *http.Request) { Detail: "The chat has no conversation to summarize after the latest compaction.", }) case errors.Is(err, chatstate.ErrTransitionNotAllowed): - // Covers every non-waiting state: running, interrupting, - // requires-action, and error. "Busy" would misdescribe an - // errored chat, so keep the message state-neutral. httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ Message: "Cannot compact the chat in its current state.", - Detail: "Compaction is only available while the chat is idle.", + Detail: "Compaction is not available while the chat is generating.", }) default: logger.Error(ctx, "failed to compact chat", slog.Error(err)) diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 7e61987d389..8da0941994c 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -9289,6 +9289,35 @@ func TestCompactChat(t *testing.T) { require.False(t, persisted.CompactionRequestedAt.Valid) }) + t.Run("FromErrorState", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + user := coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModelConfig(t, client) + chat := seedCompactableChat(t, db, user.OrganizationID, user.UserID, modelConfig.ID) + + _, err := db.UpdateChatStatus(dbauthz.AsSystemRestricted(ctx), database.UpdateChatStatusParams{ + ID: chat.ID, + Status: database.ChatStatusError, + LastError: pqtype.NullRawMessage{ + RawMessage: json.RawMessage(`{"message":"context overflow"}`), + Valid: true, + }, + }) + require.NoError(t, err) + + // Response snapshot only: a worker may already be mutating + // the persisted row. + compacted, err := client.CompactChat(ctx, chat.ID) + require.NoError(t, err) + require.Equal(t, chat.ID, compacted.ID) + require.Equal(t, codersdk.ChatStatusRunning, compacted.Status) + require.Nil(t, compacted.LastError, + "compaction from the error state clears last_error") + }) + t.Run("Busy", func(t *testing.T) { t.Parallel() @@ -9310,6 +9339,7 @@ func TestCompactChat(t *testing.T) { _, err = client.CompactChat(ctx, chat.ID) sdkErr := requireSDKError(t, err, http.StatusConflict) require.Contains(t, sdkErr.Message, "Cannot compact the chat in its current state") + require.Contains(t, sdkErr.Detail, "Compaction is not available while the chat is generating.") }) t.Run("Archived", func(t *testing.T) { diff --git a/coderd/x/chatd/ARCHITECTURE.md b/coderd/x/chatd/ARCHITECTURE.md index de78ad72153..36eb0202e8f 100644 --- a/coderd/x/chatd/ARCHITECTURE.md +++ b/coderd/x/chatd/ARCHITECTURE.md @@ -116,7 +116,7 @@ I don't recommend reading the rest of section thoroughly if this is your first t - `PromoteQueuedMessage(qid)` makes a queued message the next message to process. It reorders the queue, interrupts active work, cancels pending dynamic-tool action, or promotes into history immediately as required by the input state. - `Interrupt(reason)` requests cancellation of an active generation or closes pending dynamic-tool action. It preserves queued backlog. - `CompleteRequiresAction(results)` inserts submitted tool-result messages followed by any caller-provided suffix messages, clears `requires_action_deadline_at`, and lands in `running`. It preserves queued messages. -- `RequestCompaction` records a manual compaction request on an idle chat by setting `compaction_requested_at` and landing in `running` without inserting any message. The chat worker picks the chat up like any other running chat and consumes the request. See [Manual compaction](#manual-compaction). +- `RequestCompaction` records a manual compaction request on an idle or errored chat by setting `compaction_requested_at` and landing in `running` without inserting any message. It clears `last_error` per the leave-error rule. The chat worker picks the chat up like any other running chat and consumes the request. See [Manual compaction](#manual-compaction). ### Transitions used by the chat worker @@ -154,10 +154,12 @@ stateDiagram-v2 E0 --> R0: SendMessage E0 --> R0: EditMessage + E0 --> R0: RequestCompaction E0 --> XE0: SetArchived(true) E1 --> R1: SendMessage E1 --> R0: EditMessage + E1 --> R1: RequestCompaction E1 --> E0: DeleteQueuedMessage / removed last queued E1 --> E1: DeleteQueuedMessage / queue still non-empty E1 --> R0: PromoteQueuedMessage / promoted last queued @@ -549,8 +551,10 @@ No other input states are supported. This endpoint uses `RequestCompaction`: - `W -> RequestCompaction -> R0` +- `E0 -> RequestCompaction -> R0` +- `E1 -> RequestCompaction -> R1` -No other input states are supported: busy chats get a conflict error, and archived chats are rejected. The endpoint is owner-only because the compaction runs LLM inference with the owner's delegated credentials. Inside the same transaction, after the transition succeeds, the endpoint verifies there is at least one uncompressed assistant message after the latest compaction boundary and rolls back with a "nothing to compact" conflict otherwise, so no LLM call is ever started for an empty or already-compacted chat. See [Manual compaction](#manual-compaction) for how the worker consumes the request. +No other input states are supported: generating chats get a conflict error, and archived chats are rejected. Requesting compaction from an error state clears `last_error`, so a context-overflowed chat can recover by compacting instead of re-running the same oversized prompt. The endpoint is owner-only because the compaction runs LLM inference with the owner's delegated credentials. Inside the same transaction, after the transition succeeds, the endpoint verifies there is at least one uncompressed assistant message after the latest compaction boundary and rolls back with a "nothing to compact" conflict otherwise, so no LLM call is ever started for an empty or already-compacted chat. See [Manual compaction](#manual-compaction) for how the worker consumes the request. ## Pubsub @@ -951,10 +955,10 @@ Compaction reduces the LLM prompt size by summarizing older history into a compr Users can also request a compaction on demand via `POST /api/experimental/chats/{chat}/compact` (surfaced in the web UI as the `/compact` slash command). Manual compaction is a durable one-shot request executed through the normal worker loop rather than synchronously in the HTTP handler. This reuses the worker's lock fencing, retry accounting, streamed "Summarizing..." progress parts, metrics, and debug runs, and it survives replica crashes. The flow: -1. The endpoint applies the `RequestCompaction` transition: only allowed from `W`, sets `chats.compaction_requested_at = now()`, lands in `R0` without inserting any message, and publishes a status-change pubsub event to wake workers. A timestamp is used instead of a boolean for debuggability. AI Gateway attribution needs no per-request key: generation preparation resolves the owner's synthetic API key like any other turn. +1. The endpoint applies the `RequestCompaction` transition: allowed from `W`, `E0`, and `E1`, sets `chats.compaction_requested_at = now()`, clears `last_error`, lands in `R0` (or `R1` from `E1`, preserving the queue) without inserting any message, and publishes a status-change pubsub event to wake workers. A timestamp is used instead of a boolean for debuggability. AI Gateway attribution needs no per-request key: generation preparation resolves the owner's synthetic API key like any other turn. 2. The generation goroutine's decision logic checks `compaction_requested_at` after the unresolved local/dynamic tool guards but before the history-completeness check (an idle chat's history is otherwise complete, which would end the turn). If the marker is set and at least one uncompressed assistant message exists after the latest compaction boundary, it selects a forced compaction; if there is nothing to compact, the marker is ignored and the turn finishes normally, clearing it. 3. A forced compaction bypasses the automatic threshold gates (usage below threshold, unknown context window, and the threshold=100 disable) and stamps `source: "manual"` instead of `source: "automatic"` into the `chat_summarized` tool call arguments, tool result JSON, and streamed parts so clients can render manual compactions distinctly. -4. The compaction `CommitStep` consumes the request by clearing `compaction_requested_at` in the same transaction that commits the summary triplet. The next decision pass finds the history complete and finishes the turn, so the chat returns to `waiting` with no assistant follow-up. A `post_compact` hook effect is the one exception: because the decision reads user-visible history, an effect that commits a user-visible message leaves the history incomplete and the turn continues with an assistant response. A model-only effect such as `model_context` reaches the model without resuming generation. +4. The compaction `CommitStep` consumes the request by clearing `compaction_requested_at` in the same transaction that commits the summary triplet. The next decision pass finds the history complete and finishes the turn, so a chat with an empty queue returns to `waiting` with no assistant follow-up; a chat compacted from `E1` proceeds to its queued messages instead. A `post_compact` hook effect is the one exception: because the decision reads user-visible history, an effect that commits a user-visible message leaves the history incomplete and the turn continues with an assistant response. A model-only effect such as `model_context` reaches the model without resuming generation. The `compaction_requested_at` marker is one-shot: transitions that keep an active turn alive (`Acquire`, `Abandon`, `SetArchived`, queueing a message on a busy chat) carry it forward, while every other transition that rewrites the execution state (`FinishTurn`, `FinishError`, `Interrupt`, `EditMessage`, `PromoteQueuedMessage`, `CancelRequiresAction`, `ReconcileInvalidState`, and so on) clears it by construction, so a stale request can never replay on a later turn. diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 54f8a022b53..7dea253b2ae 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -2279,16 +2279,16 @@ func (p *Server) InterruptChat( // CompactChat records a manual compaction request through the // chatstate.RequestCompaction transition and wakes workers. The chat -// must be idle (waiting); the worker then generates and commits the -// compaction summary through the normal generation loop, bypassing -// the usage threshold, and the chat returns to waiting with no -// assistant follow-up unless a post_compact hook commits a -// user-visible message, which leaves the history incomplete and -// resumes generation. +// must be idle (waiting) or errored; the request clears any stored +// error. The worker then generates and commits the compaction summary +// through the normal generation loop, bypassing the usage threshold, +// and the chat returns to waiting with no assistant follow-up unless +// queued messages remain or a post_compact hook commits a +// user-visible message. // // Returns the post-transition chat and an error so callers can map // state conflicts deliberately: archived chats return ErrChatArchived, -// non-idle chats return a chatstate.ErrTransitionNotAllowed wrapper, +// generating chats return a chatstate.ErrTransitionNotAllowed wrapper, // and chats with no compactable conversation return // ErrNothingToCompact. func (p *Server) CompactChat( diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index 6cb1a5a6935..208c92df7ef 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -5431,6 +5431,65 @@ func TestActiveServer_ManualCompaction(t *testing.T) { require.Equal(t, int32(2), streamCount.Load()) }) + t.Run("compacts an errored chat and clears last_error", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + db, ps := dbtestutil.NewDB(t) + var compactionRequests atomic.Int32 + anthropicURL := chattest.NewAnthropic(t, func(req *chattest.AnthropicRequest) chattest.AnthropicResponse { + body := anthropicRequestBody(t, *req) + if !req.Stream { + if strings.Contains(body, "You are performing a context compaction") { + compactionRequests.Add(1) + return anthropicCompactionResponse(compactionSummary) + } + return chattest.AnthropicNonStreamingResponse("title") + } + return chattest.AnthropicStreamingResponse(chattest.AnthropicTextChunks("assistant answer")...) + }) + user, org, model := seedAnthropicChatDependencies(t, db, anthropicURL) + + server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { + cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, anthropicURL, chattest.WithPreservePath())) + }) + chat := createChatThroughServer(ctx, t, db, server, org.ID, user.ID, model.ID, "hello from the user") + chat = waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusWaiting) + + machine := chatstate.NewChatMachine(db, ps, chat.ID) + require.NoError(t, machine.Update(ctx, func(tx *chatstate.Tx, _ database.Store) error { + _, err := tx.FinishError(chatstate.FinishErrorInput{ + LastError: mustChatLastErrorRawMessage(t, codersdk.ChatError{ + Message: "input length exceeds the maximum allowed input length", + Kind: codersdk.ChatErrorKindGeneric, + }), + }) + return err + })) + chat, err := db.GetChatByID(ctx, chat.ID) + require.NoError(t, err) + require.Equal(t, database.ChatStatusError, chat.Status) + require.True(t, chat.LastError.Valid) + + compacted, err := server.CompactChat(ctx, chat) + require.NoError(t, err) + require.Equal(t, database.ChatStatusRunning, compacted.Status) + require.False(t, compacted.LastError.Valid, + "requesting compaction from the error state clears last_error") + + chat = waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusWaiting) + require.False(t, chat.LastError.Valid) + require.False(t, chat.CompactionRequestedAt.Valid) + require.Equal(t, int32(1), compactionRequests.Load(), "one forced compaction call") + + messages := chatMessages(ctx, t, db, chat.ID) + promptMessages, err := db.GetChatMessagesForPromptByChatID(ctx, chat.ID) + require.NoError(t, err) + compressed := compressedChatSummarizedMessages(t, append(promptMessages, messages...)) + require.Len(t, compressed.summaries, 1, + "prompt history contains the compressed summary boundary") + }) + t.Run("busy chat rejects manual compaction", func(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/chatstate/request_compaction_test.go b/coderd/x/chatd/chatstate/request_compaction_test.go index 5599ac79baa..aaaf26ba61d 100644 --- a/coderd/x/chatd/chatstate/request_compaction_test.go +++ b/coderd/x/chatd/chatstate/request_compaction_test.go @@ -213,13 +213,13 @@ func TestRequestCompaction_ClearedByNewTurn(t *testing.T) { } // TestRequestCompaction_RejectedWhenBusyOrArchived pins the matrix -// boundaries callers rely on for 409 mapping: only W admits the -// transition. +// boundaries callers rely on for 409 mapping: only W, E0, and E1 +// admit the transition. func TestRequestCompaction_RejectedWhenBusyOrArchived(t *testing.T) { t.Parallel() for _, from := range []chatstate.ExecutionState{ - chatstate.StateR0, chatstate.StateE0, chatstate.StateXW, + chatstate.StateR0, chatstate.StateXW, } { t.Run(string(from), func(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/chatstate/transition.go b/coderd/x/chatd/chatstate/transition.go index d6f4a03af90..d2b5be18422 100644 --- a/coderd/x/chatd/chatstate/transition.go +++ b/coderd/x/chatd/chatstate/transition.go @@ -84,9 +84,10 @@ var transitionMatrix = map[ExecutionState]map[Transition][]ExecutionState{ TransitionFinishError: {StateE0}, }, StateE0: { - TransitionSetArchived: {StateXE0}, - TransitionSendMessage: {StateR0}, - TransitionEditMessage: {StateR0}, + TransitionSetArchived: {StateXE0}, + TransitionSendMessage: {StateR0}, + TransitionEditMessage: {StateR0}, + TransitionRequestCompaction: {StateR0}, }, StateE1: { TransitionSetArchived: {StateXE1}, @@ -94,6 +95,7 @@ var transitionMatrix = map[ExecutionState]map[Transition][]ExecutionState{ TransitionEditMessage: {StateR0}, TransitionDeleteQueuedMessage: {StateE0, StateE1}, TransitionPromoteQueuedMessage: {StateR0, StateR1}, + TransitionRequestCompaction: {StateR1}, }, StateR0: { TransitionSendMessage: {StateR1, StateI1}, diff --git a/coderd/x/chatd/chatstate/transitions.go b/coderd/x/chatd/chatstate/transitions.go index 30c3e31b4a5..f335943f833 100644 --- a/coderd/x/chatd/chatstate/transitions.go +++ b/coderd/x/chatd/chatstate/transitions.go @@ -676,12 +676,13 @@ type RequestCompactionResult struct { Chat database.Chat } -// RequestCompaction records a manual compaction request and hands ownership -// off to a worker. The transition changes no history, so the previous runner -// cannot detect the work from its existing running snapshot. Clearing ownership -// makes ChatMachine.Update publish an ownership hint for worker acquisition. +// RequestCompaction records a manual compaction request, clears any +// prior error, and hands ownership off to a worker. The transition +// changes no history, so the previous runner cannot detect the work +// from its existing running snapshot. Clearing ownership makes +// ChatMachine.Update publish an ownership hint for worker acquisition. func (tx *Tx) RequestCompaction(_ RequestCompactionInput) (RequestCompactionResult, error) { - chat, _, err := tx.requireFromAllowed(TransitionRequestCompaction) + _, _, err := tx.requireFromAllowed(TransitionRequestCompaction) if err != nil { return RequestCompactionResult{}, err } @@ -694,7 +695,7 @@ func (tx *Tx) RequestCompaction(_ RequestCompactionInput) (RequestCompactionResu Archived: false, WorkerID: uuid.NullUUID{}, RunnerID: uuid.NullUUID{}, - LastError: chat.LastError, + LastError: pqtype.NullRawMessage{}, RequiresActionDeadlineAt: sql.NullTime{}, CompactionRequestedAt: sql.NullTime{Time: now, Valid: true}, }) diff --git a/coderd/x/chatd/chatstate/transitions_matrix_test.go b/coderd/x/chatd/chatstate/transitions_matrix_test.go index 15258c53c26..6bb9b858f26 100644 --- a/coderd/x/chatd/chatstate/transitions_matrix_test.go +++ b/coderd/x/chatd/chatstate/transitions_matrix_test.go @@ -788,9 +788,11 @@ func matrixCases() []transitionCaseSpec { editMessageCase(chatstate.StateA0), editMessageCase(chatstate.StateA1), - // RequestCompaction: only from idle (W), lands in R0 with - // the one-shot marker set and no history/queue mutation. - requestCompactionCase(), + // RequestCompaction: sets the one-shot marker, clears + // last_error, and mutates no history or queue. + requestCompactionCase(chatstate.StateW, chatstate.StateR0), + requestCompactionCase(chatstate.StateE0, chatstate.StateR0), + requestCompactionCase(chatstate.StateE1, chatstate.StateR1), // DeleteQueuedMessage cases. Empty-tail want collapses the // classified state (E1->E0, R1->R0, I1->I0, A1->A0). The @@ -1432,11 +1434,11 @@ func promoteQueuedCase(from, want chatstate.ExecutionState, shape queueShape, ta return spec } -func requestCompactionCase() transitionCaseSpec { +func requestCompactionCase(from, want chatstate.ExecutionState) transitionCaseSpec { return transitionCaseSpec{ transition: chatstate.TransitionRequestCompaction, - from: chatstate.StateW, - want: chatstate.StateR0, + from: from, + want: want, apply: applyRequestCompaction, assert: func(ctx context.Context, t *testing.T, f *testFixture, seeded seededChat, base snapshotBaseline, result transitionCaseResult) { after, err := f.DB.GetChatByID(ctx, seeded.chatID) @@ -1445,6 +1447,8 @@ func requestCompactionCase() transitionCaseSpec { "RequestCompaction sets status running") require.True(t, after.CompactionRequestedAt.Valid, "RequestCompaction sets compaction_requested_at") + require.False(t, after.LastError.Valid, + "RequestCompaction clears last_error") require.Equal(t, base.historyIDs, activeHistoryIDs(ctx, t, f, seeded.chatID), "RequestCompaction inserts no history messages") require.Equal(t, base.queueIDs, queuedIDsByPosition(ctx, t, f, seeded.chatID), diff --git a/codersdk/chats.go b/codersdk/chats.go index df95d89a520..e55986342da 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -3064,10 +3064,10 @@ func (c *ExperimentalClient) InterruptChat(ctx context.Context, chatID uuid.UUID return chat, ReadBodyAsJSON(res, &chat) } -// CompactChat requests a manual context compaction on an idle chat. -// The compaction runs asynchronously through the chat worker and -// bypasses the automatic usage threshold; the chat returns to waiting -// once the summary is committed. +// CompactChat requests a manual context compaction on an idle or +// errored chat, clearing any stored error. The compaction runs +// asynchronously through the chat worker and bypasses the automatic +// usage threshold. func (c *ExperimentalClient) CompactChat(ctx context.Context, chatID uuid.UUID) (Chat, error) { res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/experimental/chats/%s/compact", chatID), nil) if err != nil { diff --git a/docs/ai-coder/agents/architecture.md b/docs/ai-coder/agents/architecture.md index 021725b3ae4..225aa8344f7 100644 --- a/docs/ai-coder/agents/architecture.md +++ b/docs/ai-coder/agents/architecture.md @@ -93,8 +93,9 @@ from the model's context window. This happens transparently and keeps long-running sessions productive. You can also trigger a compaction on demand by sending `/compact` while the -agent is idle. Manual compaction runs the same summarization regardless of -current token usage and is labeled as manual in the conversation. +agent is idle or in an error state, which clears the error. Manual compaction +runs the same summarization regardless of current token usage and is labeled +as manual in the conversation. ### Message queuing diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 078da917beb..7c12690a077 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -3415,9 +3415,9 @@ class ExperimentalApiMethods { }; /** - * Requests a manual context compaction on an idle chat. The - * compaction runs asynchronously through the chat worker and - * bypasses the automatic usage threshold. + * Requests a manual context compaction on an idle or errored chat, + * clearing any stored error. The compaction runs asynchronously + * through the chat worker and bypasses the automatic usage threshold. */ compactChat = async (chatId: string): Promise => { const response = await this.axios.post( From fc7d9cb7efff7d5626ae473fafa93eb223538873 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:26:50 +0000 Subject: [PATCH 2/7] fix: reset generation attempt on manual compaction request A chat that errored after exhausting retryable provider failures keeps generation_attempt at the cap; RequestCompaction inserts no history, so the history-change trigger never resets it and the recovery compaction would inherit the spent retry budget. --- coderd/database/dbauthz/dbauthz.go | 11 ++++++++ coderd/database/dbauthz/dbauthz_test.go | 6 +++++ coderd/database/dbmetrics/querymetrics.go | 8 ++++++ coderd/database/dbmock/dbmock.go | 14 +++++++++++ coderd/database/querier.go | 4 +++ coderd/database/queries.sql.go | 15 +++++++++++ coderd/database/queries/chats.sql | 9 +++++++ coderd/x/chatd/ARCHITECTURE.md | 4 +-- .../chatstate/request_compaction_test.go | 25 +++++++++++++++++++ coderd/x/chatd/chatstate/transitions.go | 8 ++++++ 10 files changed, 102 insertions(+), 2 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 3f68893f59b..69c4cb3a92a 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -7045,6 +7045,17 @@ func (q *querier) ReorderChatQueuedMessageToHead(ctx context.Context, arg databa return q.db.ReorderChatQueuedMessageToHead(ctx, arg) } +func (q *querier) ResetChatGenerationAttempt(ctx context.Context, id uuid.UUID) error { + chat, err := q.db.GetChatByID(ctx, id) + if err != nil { + return err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { + return err + } + return q.db.ResetChatGenerationAttempt(ctx, id) +} + func (q *querier) RevokeDBCryptKey(ctx context.Context, activeKeyDigest string) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { return err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index a1dd4f79731..9219695dcaf 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1269,6 +1269,12 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().IncrementChatGenerationAttempt(gomock.Any(), chat.ID).Return(int64(7), nil).AnyTimes() check.Args(chat.ID).Asserts(chat, policy.ActionUpdate).Returns(int64(7)) })) + s.Run("ResetChatGenerationAttempt", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().ResetChatGenerationAttempt(gomock.Any(), chat.ID).Return(nil).AnyTimes() + check.Args(chat.ID).Asserts(chat, policy.ActionUpdate) + })) s.Run("UpdateChatRetryState", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { chat := testutil.Fake(s.T(), faker, database.Chat{}) arg := database.UpdateChatRetryStateParams{ID: chat.ID, RetryState: []byte(`{"attempt":1}`)} diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index f27c4271dbe..eee1895c785 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -4953,6 +4953,14 @@ func (m queryMetricsStore) ReorderChatQueuedMessageToHead(ctx context.Context, a return r0, r1 } +func (m queryMetricsStore) ResetChatGenerationAttempt(ctx context.Context, id uuid.UUID) error { + start := time.Now() + r0 := m.s.ResetChatGenerationAttempt(ctx, id) + m.queryLatencies.WithLabelValues("ResetChatGenerationAttempt").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ResetChatGenerationAttempt").Inc() + return r0 +} + func (m queryMetricsStore) RevokeDBCryptKey(ctx context.Context, activeKeyDigest string) error { start := time.Now() r0 := m.s.RevokeDBCryptKey(ctx, activeKeyDigest) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index f172027dad5..bce5868ad1a 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -9355,6 +9355,20 @@ func (mr *MockStoreMockRecorder) ReorderChatQueuedMessageToHead(ctx, arg any) *g return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReorderChatQueuedMessageToHead", reflect.TypeOf((*MockStore)(nil).ReorderChatQueuedMessageToHead), ctx, arg) } +// ResetChatGenerationAttempt mocks base method. +func (m *MockStore) ResetChatGenerationAttempt(ctx context.Context, id uuid.UUID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ResetChatGenerationAttempt", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// ResetChatGenerationAttempt indicates an expected call of ResetChatGenerationAttempt. +func (mr *MockStoreMockRecorder) ResetChatGenerationAttempt(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResetChatGenerationAttempt", reflect.TypeOf((*MockStore)(nil).ResetChatGenerationAttempt), ctx, id) +} + // RevokeDBCryptKey mocks base method. func (m *MockStore) RevokeDBCryptKey(ctx context.Context, activeKeyDigest string) error { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index a7f52a88464..58086afa6e0 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1331,6 +1331,10 @@ type sqlcQuerier interface { // Sets the target queued message's position to one less than the // current minimum position for that chat, moving it to the head. ReorderChatQueuedMessageToHead(ctx context.Context, arg ReorderChatQueuedMessageToHeadParams) (int64, error) + // Resets generation_attempt so the next turn starts with a fresh + // retry budget. The sync_chat_retry_state trigger clears retry_state + // when the attempt value changes. + ResetChatGenerationAttempt(ctx context.Context, id uuid.UUID) error RevokeDBCryptKey(ctx context.Context, activeKeyDigest string) error // Note that this selects from the CTE, not the original table. The CTE is named // the same as the original table to trick sqlc into reusing the existing struct diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 1682eb894a0..3b41610d9a2 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -10853,6 +10853,21 @@ func (q *sqlQuerier) ReorderChatQueuedMessageToHead(ctx context.Context, arg Reo return result.RowsAffected() } +const resetChatGenerationAttempt = `-- name: ResetChatGenerationAttempt :exec +UPDATE chats +SET generation_attempt = 0, updated_at = NOW() +WHERE id = $1::uuid + AND generation_attempt <> 0 +` + +// Resets generation_attempt so the next turn starts with a fresh +// retry budget. The sync_chat_retry_state trigger clears retry_state +// when the attempt value changes. +func (q *sqlQuerier) ResetChatGenerationAttempt(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, resetChatGenerationAttempt, id) + return err +} + const setChatContextSnapshot = `-- name: SetChatContextSnapshot :exec UPDATE chats SET diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index 290e0c17f9d..d3c2e1b8a37 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -2624,6 +2624,15 @@ SET generation_attempt = generation_attempt + 1, updated_at = NOW() WHERE id = @id::uuid RETURNING generation_attempt; +-- name: ResetChatGenerationAttempt :exec +-- Resets generation_attempt so the next turn starts with a fresh +-- retry budget. The sync_chat_retry_state trigger clears retry_state +-- when the attempt value changes. +UPDATE chats +SET generation_attempt = 0, updated_at = NOW() +WHERE id = @id::uuid + AND generation_attempt <> 0; + -- name: GetDatabaseNow :one -- Returns the current database timestamp. Used so transitions that -- record deadlines or heartbeats rely on a clock that is consistent diff --git a/coderd/x/chatd/ARCHITECTURE.md b/coderd/x/chatd/ARCHITECTURE.md index 36eb0202e8f..ba8b8382f27 100644 --- a/coderd/x/chatd/ARCHITECTURE.md +++ b/coderd/x/chatd/ARCHITECTURE.md @@ -116,7 +116,7 @@ I don't recommend reading the rest of section thoroughly if this is your first t - `PromoteQueuedMessage(qid)` makes a queued message the next message to process. It reorders the queue, interrupts active work, cancels pending dynamic-tool action, or promotes into history immediately as required by the input state. - `Interrupt(reason)` requests cancellation of an active generation or closes pending dynamic-tool action. It preserves queued backlog. - `CompleteRequiresAction(results)` inserts submitted tool-result messages followed by any caller-provided suffix messages, clears `requires_action_deadline_at`, and lands in `running`. It preserves queued messages. -- `RequestCompaction` records a manual compaction request on an idle or errored chat by setting `compaction_requested_at` and landing in `running` without inserting any message. It clears `last_error` per the leave-error rule. The chat worker picks the chat up like any other running chat and consumes the request. See [Manual compaction](#manual-compaction). +- `RequestCompaction` records a manual compaction request on an idle or errored chat by setting `compaction_requested_at` and landing in `running` without inserting any message. It clears `last_error` per the leave-error rule and resets `generation_attempt` so the compaction turn gets a fresh retry budget. The chat worker picks the chat up like any other running chat and consumes the request. See [Manual compaction](#manual-compaction). ### Transitions used by the chat worker @@ -955,7 +955,7 @@ Compaction reduces the LLM prompt size by summarizing older history into a compr Users can also request a compaction on demand via `POST /api/experimental/chats/{chat}/compact` (surfaced in the web UI as the `/compact` slash command). Manual compaction is a durable one-shot request executed through the normal worker loop rather than synchronously in the HTTP handler. This reuses the worker's lock fencing, retry accounting, streamed "Summarizing..." progress parts, metrics, and debug runs, and it survives replica crashes. The flow: -1. The endpoint applies the `RequestCompaction` transition: allowed from `W`, `E0`, and `E1`, sets `chats.compaction_requested_at = now()`, clears `last_error`, lands in `R0` (or `R1` from `E1`, preserving the queue) without inserting any message, and publishes a status-change pubsub event to wake workers. A timestamp is used instead of a boolean for debuggability. AI Gateway attribution needs no per-request key: generation preparation resolves the owner's synthetic API key like any other turn. +1. The endpoint applies the `RequestCompaction` transition: allowed from `W`, `E0`, and `E1`, sets `chats.compaction_requested_at = now()`, clears `last_error`, resets `generation_attempt` (the transition inserts no history, so the history-change trigger cannot grant the fresh retry budget), lands in `R0` (or `R1` from `E1`, preserving the queue) without inserting any message, and publishes a status-change pubsub event to wake workers. A timestamp is used instead of a boolean for debuggability. AI Gateway attribution needs no per-request key: generation preparation resolves the owner's synthetic API key like any other turn. 2. The generation goroutine's decision logic checks `compaction_requested_at` after the unresolved local/dynamic tool guards but before the history-completeness check (an idle chat's history is otherwise complete, which would end the turn). If the marker is set and at least one uncompressed assistant message exists after the latest compaction boundary, it selects a forced compaction; if there is nothing to compact, the marker is ignored and the turn finishes normally, clearing it. 3. A forced compaction bypasses the automatic threshold gates (usage below threshold, unknown context window, and the threshold=100 disable) and stamps `source: "manual"` instead of `source: "automatic"` into the `chat_summarized` tool call arguments, tool result JSON, and streamed parts so clients can render manual compactions distinctly. 4. The compaction `CommitStep` consumes the request by clearing `compaction_requested_at` in the same transaction that commits the summary triplet. The next decision pass finds the history complete and finishes the turn, so a chat with an empty queue returns to `waiting` with no assistant follow-up; a chat compacted from `E1` proceeds to its queued messages instead. A `post_compact` hook effect is the one exception: because the decision reads user-visible history, an effect that commits a user-visible message leaves the history incomplete and the turn continues with an assistant response. A model-only effect such as `model_context` reaches the model without resuming generation. diff --git a/coderd/x/chatd/chatstate/request_compaction_test.go b/coderd/x/chatd/chatstate/request_compaction_test.go index aaaf26ba61d..b483e1afb8a 100644 --- a/coderd/x/chatd/chatstate/request_compaction_test.go +++ b/coderd/x/chatd/chatstate/request_compaction_test.go @@ -212,6 +212,31 @@ func TestRequestCompaction_ClearedByNewTurn(t *testing.T) { "EditMessage starts a new turn and must clear the marker") } +// TestRequestCompaction_ResetsGenerationAttempt verifies a chat that +// errored with a spent retry budget gets a fresh one. The transition +// inserts no history, so the history-change trigger cannot reset the +// counter and the transition must do it explicitly. +func TestRequestCompaction_ResetsGenerationAttempt(t *testing.T) { + t.Parallel() + f := newTestFixture(t) + ctx := testutil.Context(t, testutil.WaitShort) + seeded := seedState(t, f, chatstate.StateE0) + for range 3 { + _, err := f.DB.IncrementChatGenerationAttempt(ctx, seeded.chatID) + require.NoError(t, err) + } + + m := chatstate.NewChatMachine(f.DB, f.Pub, seeded.chatID) + require.NoError(t, m.Update(ctx, func(tx *chatstate.Tx, store database.Store) error { + _, err := tx.RequestCompaction(chatstate.RequestCompactionInput{}) + return err + })) + + chat := f.readChat(ctx, t, seeded.chatID) + require.Zero(t, chat.GenerationAttempt, + "RequestCompaction must reset the generation attempt counter") +} + // TestRequestCompaction_RejectedWhenBusyOrArchived pins the matrix // boundaries callers rely on for 409 mapping: only W, E0, and E1 // admit the transition. diff --git a/coderd/x/chatd/chatstate/transitions.go b/coderd/x/chatd/chatstate/transitions.go index f335943f833..ca56936e969 100644 --- a/coderd/x/chatd/chatstate/transitions.go +++ b/coderd/x/chatd/chatstate/transitions.go @@ -681,6 +681,11 @@ type RequestCompactionResult struct { // changes no history, so the previous runner cannot detect the work // from its existing running snapshot. Clearing ownership makes // ChatMachine.Update publish an ownership hint for worker acquisition. +// +// The generation attempt counter is reset so the compaction turn gets +// a fresh retry budget: history-change triggers normally reset it, but +// this transition inserts no history, and a chat that errored after +// exhausting retries would otherwise inherit the spent budget. func (tx *Tx) RequestCompaction(_ RequestCompactionInput) (RequestCompactionResult, error) { _, _, err := tx.requireFromAllowed(TransitionRequestCompaction) if err != nil { @@ -690,6 +695,9 @@ func (tx *Tx) RequestCompaction(_ RequestCompactionInput) (RequestCompactionResu if err != nil { return RequestCompactionResult{}, xerrors.Errorf("get db now: %w", err) } + if err := tx.store.ResetChatGenerationAttempt(tx.ctx, tx.chatID); err != nil { + return RequestCompactionResult{}, xerrors.Errorf("reset generation attempt: %w", err) + } updated, err := tx.applyExecutionState(executionStateUpdate{ Status: database.ChatStatusRunning, Archived: false, From 705213cf25b344fb11d49d7359ae7519ff162166 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:43:17 +0000 Subject: [PATCH 3/7] fix: only reset compaction retry budget when it is exhausted Rewinding generation_attempt while sub-cap can reuse message part buffer episode keys (chat, history_version, attempt) still inside the 15s closed-episode retention window on the erroring replica. Sub-cap counters continue forward instead; at exhaustion the rewind is collision-free because backoff spacing expires the earliest episodes long before the budget runs out. --- coderd/x/chatd/ARCHITECTURE.md | 4 +- .../chatstate/request_compaction_test.go | 54 ++++++++++++------- coderd/x/chatd/chatstate/transitions.go | 24 ++++++--- 3 files changed, 54 insertions(+), 28 deletions(-) diff --git a/coderd/x/chatd/ARCHITECTURE.md b/coderd/x/chatd/ARCHITECTURE.md index ba8b8382f27..30505abd76d 100644 --- a/coderd/x/chatd/ARCHITECTURE.md +++ b/coderd/x/chatd/ARCHITECTURE.md @@ -116,7 +116,7 @@ I don't recommend reading the rest of section thoroughly if this is your first t - `PromoteQueuedMessage(qid)` makes a queued message the next message to process. It reorders the queue, interrupts active work, cancels pending dynamic-tool action, or promotes into history immediately as required by the input state. - `Interrupt(reason)` requests cancellation of an active generation or closes pending dynamic-tool action. It preserves queued backlog. - `CompleteRequiresAction(results)` inserts submitted tool-result messages followed by any caller-provided suffix messages, clears `requires_action_deadline_at`, and lands in `running`. It preserves queued messages. -- `RequestCompaction` records a manual compaction request on an idle or errored chat by setting `compaction_requested_at` and landing in `running` without inserting any message. It clears `last_error` per the leave-error rule and resets `generation_attempt` so the compaction turn gets a fresh retry budget. The chat worker picks the chat up like any other running chat and consumes the request. See [Manual compaction](#manual-compaction). +- `RequestCompaction` records a manual compaction request on an idle or errored chat by setting `compaction_requested_at` and landing in `running` without inserting any message. It clears `last_error` per the leave-error rule, and resets `generation_attempt` only when the previous turn exhausted the retry budget; sub-cap counters continue forward so message part buffer episode keys are never rewound into the closed-episode retention window. The chat worker picks the chat up like any other running chat and consumes the request. See [Manual compaction](#manual-compaction). ### Transitions used by the chat worker @@ -955,7 +955,7 @@ Compaction reduces the LLM prompt size by summarizing older history into a compr Users can also request a compaction on demand via `POST /api/experimental/chats/{chat}/compact` (surfaced in the web UI as the `/compact` slash command). Manual compaction is a durable one-shot request executed through the normal worker loop rather than synchronously in the HTTP handler. This reuses the worker's lock fencing, retry accounting, streamed "Summarizing..." progress parts, metrics, and debug runs, and it survives replica crashes. The flow: -1. The endpoint applies the `RequestCompaction` transition: allowed from `W`, `E0`, and `E1`, sets `chats.compaction_requested_at = now()`, clears `last_error`, resets `generation_attempt` (the transition inserts no history, so the history-change trigger cannot grant the fresh retry budget), lands in `R0` (or `R1` from `E1`, preserving the queue) without inserting any message, and publishes a status-change pubsub event to wake workers. A timestamp is used instead of a boolean for debuggability. AI Gateway attribution needs no per-request key: generation preparation resolves the owner's synthetic API key like any other turn. +1. The endpoint applies the `RequestCompaction` transition: allowed from `W`, `E0`, and `E1`, sets `chats.compaction_requested_at = now()`, clears `last_error`, resets `generation_attempt` when the previous turn exhausted the retry budget (the transition inserts no history, so the history-change trigger cannot grant a fresh budget; sub-cap counters continue forward because rewinding would reuse buffered episode keys retained after the failed turn, while at exhaustion the earliest attempts' episodes have long expired), lands in `R0` (or `R1` from `E1`, preserving the queue) without inserting any message, and publishes a status-change pubsub event to wake workers. A timestamp is used instead of a boolean for debuggability. AI Gateway attribution needs no per-request key: generation preparation resolves the owner's synthetic API key like any other turn. 2. The generation goroutine's decision logic checks `compaction_requested_at` after the unresolved local/dynamic tool guards but before the history-completeness check (an idle chat's history is otherwise complete, which would end the turn). If the marker is set and at least one uncompressed assistant message exists after the latest compaction boundary, it selects a forced compaction; if there is nothing to compact, the marker is ignored and the turn finishes normally, clearing it. 3. A forced compaction bypasses the automatic threshold gates (usage below threshold, unknown context window, and the threshold=100 disable) and stamps `source: "manual"` instead of `source: "automatic"` into the `chat_summarized` tool call arguments, tool result JSON, and streamed parts so clients can render manual compactions distinctly. 4. The compaction `CommitStep` consumes the request by clearing `compaction_requested_at` in the same transaction that commits the summary triplet. The next decision pass finds the history complete and finishes the turn, so a chat with an empty queue returns to `waiting` with no assistant follow-up; a chat compacted from `E1` proceeds to its queued messages instead. A `post_compact` hook effect is the one exception: because the decision reads user-visible history, an effect that commits a user-visible message leaves the history incomplete and the turn continues with an assistant response. A model-only effect such as `model_context` reaches the model without resuming generation. diff --git a/coderd/x/chatd/chatstate/request_compaction_test.go b/coderd/x/chatd/chatstate/request_compaction_test.go index b483e1afb8a..48b82eb958d 100644 --- a/coderd/x/chatd/chatstate/request_compaction_test.go +++ b/coderd/x/chatd/chatstate/request_compaction_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/x/chatd/chatretry" "github.com/coder/coder/v2/coderd/x/chatd/chatstate" "github.com/coder/coder/v2/testutil" ) @@ -212,29 +213,44 @@ func TestRequestCompaction_ClearedByNewTurn(t *testing.T) { "EditMessage starts a new turn and must clear the marker") } -// TestRequestCompaction_ResetsGenerationAttempt verifies a chat that -// errored with a spent retry budget gets a fresh one. The transition -// inserts no history, so the history-change trigger cannot reset the -// counter and the transition must do it explicitly. -func TestRequestCompaction_ResetsGenerationAttempt(t *testing.T) { +// TestRequestCompaction_GenerationAttemptBudget verifies the retry +// budget handling: an exhausted counter is reset so the compaction +// turn is not refused its first transient retry, while a sub-cap +// counter continues forward because rewinding would reuse message +// part buffer episode keys still inside the closed-episode retention +// window. +func TestRequestCompaction_GenerationAttemptBudget(t *testing.T) { t.Parallel() - f := newTestFixture(t) - ctx := testutil.Context(t, testutil.WaitShort) - seeded := seedState(t, f, chatstate.StateE0) - for range 3 { - _, err := f.DB.IncrementChatGenerationAttempt(ctx, seeded.chatID) - require.NoError(t, err) + + cases := []struct { + name string + attempts int + wantAttempt int64 + }{ + {name: "exhausted budget resets", attempts: chatretry.MaxAttempts, wantAttempt: 0}, + {name: "sub-cap budget continues forward", attempts: 3, wantAttempt: 3}, } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + f := newTestFixture(t) + ctx := testutil.Context(t, testutil.WaitShort) + seeded := seedState(t, f, chatstate.StateE0) + for range tc.attempts { + _, err := f.DB.IncrementChatGenerationAttempt(ctx, seeded.chatID) + require.NoError(t, err) + } - m := chatstate.NewChatMachine(f.DB, f.Pub, seeded.chatID) - require.NoError(t, m.Update(ctx, func(tx *chatstate.Tx, store database.Store) error { - _, err := tx.RequestCompaction(chatstate.RequestCompactionInput{}) - return err - })) + m := chatstate.NewChatMachine(f.DB, f.Pub, seeded.chatID) + require.NoError(t, m.Update(ctx, func(tx *chatstate.Tx, store database.Store) error { + _, err := tx.RequestCompaction(chatstate.RequestCompactionInput{}) + return err + })) - chat := f.readChat(ctx, t, seeded.chatID) - require.Zero(t, chat.GenerationAttempt, - "RequestCompaction must reset the generation attempt counter") + chat := f.readChat(ctx, t, seeded.chatID) + require.Equal(t, tc.wantAttempt, chat.GenerationAttempt) + }) + } } // TestRequestCompaction_RejectedWhenBusyOrArchived pins the matrix diff --git a/coderd/x/chatd/chatstate/transitions.go b/coderd/x/chatd/chatstate/transitions.go index ca56936e969..581d1c2cfb2 100644 --- a/coderd/x/chatd/chatstate/transitions.go +++ b/coderd/x/chatd/chatstate/transitions.go @@ -14,6 +14,7 @@ import ( "github.com/coder/coder/v2/coderd/database" coderdpubsub "github.com/coder/coder/v2/coderd/pubsub" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" + "github.com/coder/coder/v2/coderd/x/chatd/chatretry" "github.com/coder/coder/v2/codersdk" ) @@ -682,12 +683,19 @@ type RequestCompactionResult struct { // from its existing running snapshot. Clearing ownership makes // ChatMachine.Update publish an ownership hint for worker acquisition. // -// The generation attempt counter is reset so the compaction turn gets -// a fresh retry budget: history-change triggers normally reset it, but -// this transition inserts no history, and a chat that errored after -// exhausting retries would otherwise inherit the spent budget. +// The generation attempt counter is reset only when the previous turn +// exhausted the retry budget: this transition inserts no history, so +// the history-change triggers cannot grant a fresh budget, and an +// exhausted counter would make the retry gate refuse the compaction +// turn's first transient failure. Sub-cap counters continue forward +// instead of rewinding because message part buffer episodes are keyed +// by (chat, history_version, generation_attempt) and closed episodes +// are retained briefly; a rewind inside that window would collide. +// At exhaustion the rewind is collision-free: retry backoff spacing +// guarantees the earliest attempts' episodes expired long before the +// budget ran out. func (tx *Tx) RequestCompaction(_ RequestCompactionInput) (RequestCompactionResult, error) { - _, _, err := tx.requireFromAllowed(TransitionRequestCompaction) + chat, _, err := tx.requireFromAllowed(TransitionRequestCompaction) if err != nil { return RequestCompactionResult{}, err } @@ -695,8 +703,10 @@ func (tx *Tx) RequestCompaction(_ RequestCompactionInput) (RequestCompactionResu if err != nil { return RequestCompactionResult{}, xerrors.Errorf("get db now: %w", err) } - if err := tx.store.ResetChatGenerationAttempt(tx.ctx, tx.chatID); err != nil { - return RequestCompactionResult{}, xerrors.Errorf("reset generation attempt: %w", err) + if chat.GenerationAttempt >= int64(chatretry.MaxAttempts) { + if err := tx.store.ResetChatGenerationAttempt(tx.ctx, tx.chatID); err != nil { + return RequestCompactionResult{}, xerrors.Errorf("reset generation attempt: %w", err) + } } updated, err := tx.applyExecutionState(executionStateUpdate{ Status: database.ChatStatusRunning, From bf67afa93efd9ca115743749c6e0f891b02af24e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:20:35 +0000 Subject: [PATCH 4/7] fix: grant compaction turns a fresh history epoch RequestCompaction inserts no history, so the chat_messages triggers never granted the compaction turn a fresh retry budget. Conditionally resetting generation_attempt left a boundary at MaxAttempts-1 where the recovery turn inherited a spent budget, and rewinding the counter alone could collide with message part episode keys retained on the erroring replica. Advancing history_version to the transaction's new snapshot_version, exactly what a history change does, gives the turn a full budget and collision-free episode keys unconditionally. --- coderd/database/dbauthz/dbauthz.go | 22 +++++++------- coderd/database/dbauthz/dbauthz_test.go | 4 +-- coderd/database/dbmetrics/querymetrics.go | 16 +++++----- coderd/database/dbmock/dbmock.go | 28 +++++++++--------- coderd/database/querier.go | 8 ++--- coderd/database/queries.sql.go | 29 +++++++++---------- coderd/database/queries/chats.sql | 13 ++++----- coderd/x/chatd/ARCHITECTURE.md | 4 +-- .../chatstate/request_compaction_test.go | 24 +++++++-------- coderd/x/chatd/chatstate/transitions.go | 24 +++++---------- .../chatstate/transitions_matrix_test.go | 9 ++++-- 11 files changed, 86 insertions(+), 95 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 69c4cb3a92a..fd2eee57756 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1767,6 +1767,17 @@ func (q *querier) ActivityBumpWorkspace(ctx context.Context, arg database.Activi return update(q.log, q.auth, fetch, q.db.ActivityBumpWorkspace)(ctx, arg) } +func (q *querier) AdvanceChatHistoryVersion(ctx context.Context, id uuid.UUID) error { + chat, err := q.db.GetChatByID(ctx, id) + if err != nil { + return err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { + return err + } + return q.db.AdvanceChatHistoryVersion(ctx, id) +} + func (q *querier) AllUserIDs(ctx context.Context, includeSystem bool) ([]uuid.UUID, error) { // Although this technically only reads users, only system-related functions // should be allowed to call this. @@ -7045,17 +7056,6 @@ func (q *querier) ReorderChatQueuedMessageToHead(ctx context.Context, arg databa return q.db.ReorderChatQueuedMessageToHead(ctx, arg) } -func (q *querier) ResetChatGenerationAttempt(ctx context.Context, id uuid.UUID) error { - chat, err := q.db.GetChatByID(ctx, id) - if err != nil { - return err - } - if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { - return err - } - return q.db.ResetChatGenerationAttempt(ctx, id) -} - func (q *querier) RevokeDBCryptKey(ctx context.Context, activeKeyDigest string) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { return err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 9219695dcaf..5e02f092740 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1269,10 +1269,10 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().IncrementChatGenerationAttempt(gomock.Any(), chat.ID).Return(int64(7), nil).AnyTimes() check.Args(chat.ID).Asserts(chat, policy.ActionUpdate).Returns(int64(7)) })) - s.Run("ResetChatGenerationAttempt", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + s.Run("AdvanceChatHistoryVersion", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { chat := testutil.Fake(s.T(), faker, database.Chat{}) dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() - dbm.EXPECT().ResetChatGenerationAttempt(gomock.Any(), chat.ID).Return(nil).AnyTimes() + dbm.EXPECT().AdvanceChatHistoryVersion(gomock.Any(), chat.ID).Return(nil).AnyTimes() check.Args(chat.ID).Asserts(chat, policy.ActionUpdate) })) s.Run("UpdateChatRetryState", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index eee1895c785..c2eb046ccab 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -145,6 +145,14 @@ func (m queryMetricsStore) ActivityBumpWorkspace(ctx context.Context, arg databa return r0 } +func (m queryMetricsStore) AdvanceChatHistoryVersion(ctx context.Context, id uuid.UUID) error { + start := time.Now() + r0 := m.s.AdvanceChatHistoryVersion(ctx, id) + m.queryLatencies.WithLabelValues("AdvanceChatHistoryVersion").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "AdvanceChatHistoryVersion").Inc() + return r0 +} + func (m queryMetricsStore) AllUserIDs(ctx context.Context, includeSystem bool) ([]uuid.UUID, error) { start := time.Now() r0, r1 := m.s.AllUserIDs(ctx, includeSystem) @@ -4953,14 +4961,6 @@ func (m queryMetricsStore) ReorderChatQueuedMessageToHead(ctx context.Context, a return r0, r1 } -func (m queryMetricsStore) ResetChatGenerationAttempt(ctx context.Context, id uuid.UUID) error { - start := time.Now() - r0 := m.s.ResetChatGenerationAttempt(ctx, id) - m.queryLatencies.WithLabelValues("ResetChatGenerationAttempt").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ResetChatGenerationAttempt").Inc() - return r0 -} - func (m queryMetricsStore) RevokeDBCryptKey(ctx context.Context, activeKeyDigest string) error { start := time.Now() r0 := m.s.RevokeDBCryptKey(ctx, activeKeyDigest) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index bce5868ad1a..07d80ab5e42 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -118,6 +118,20 @@ func (mr *MockStoreMockRecorder) ActivityBumpWorkspace(ctx, arg any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ActivityBumpWorkspace", reflect.TypeOf((*MockStore)(nil).ActivityBumpWorkspace), ctx, arg) } +// AdvanceChatHistoryVersion mocks base method. +func (m *MockStore) AdvanceChatHistoryVersion(ctx context.Context, id uuid.UUID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AdvanceChatHistoryVersion", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// AdvanceChatHistoryVersion indicates an expected call of AdvanceChatHistoryVersion. +func (mr *MockStoreMockRecorder) AdvanceChatHistoryVersion(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AdvanceChatHistoryVersion", reflect.TypeOf((*MockStore)(nil).AdvanceChatHistoryVersion), ctx, id) +} + // AllUserIDs mocks base method. func (m *MockStore) AllUserIDs(ctx context.Context, includeSystem bool) ([]uuid.UUID, error) { m.ctrl.T.Helper() @@ -9355,20 +9369,6 @@ func (mr *MockStoreMockRecorder) ReorderChatQueuedMessageToHead(ctx, arg any) *g return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReorderChatQueuedMessageToHead", reflect.TypeOf((*MockStore)(nil).ReorderChatQueuedMessageToHead), ctx, arg) } -// ResetChatGenerationAttempt mocks base method. -func (m *MockStore) ResetChatGenerationAttempt(ctx context.Context, id uuid.UUID) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ResetChatGenerationAttempt", ctx, id) - ret0, _ := ret[0].(error) - return ret0 -} - -// ResetChatGenerationAttempt indicates an expected call of ResetChatGenerationAttempt. -func (mr *MockStoreMockRecorder) ResetChatGenerationAttempt(ctx, id any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResetChatGenerationAttempt", reflect.TypeOf((*MockStore)(nil).ResetChatGenerationAttempt), ctx, id) -} - // RevokeDBCryptKey mocks base method. func (m *MockStore) RevokeDBCryptKey(ctx context.Context, activeKeyDigest string) error { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 58086afa6e0..288c5abe98d 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -50,6 +50,10 @@ type sqlcQuerier interface { // We only bump if workspace shutdown is manual. // We only bump when 5% of the deadline has elapsed. ActivityBumpWorkspace(ctx context.Context, arg ActivityBumpWorkspaceParams) error + // Grants a turn that inserts no history the same fresh retry budget + // and message part episode keys a history change would grant. The + // sync_chat_retry_state trigger clears retry_state on the change. + AdvanceChatHistoryVersion(ctx context.Context, id uuid.UUID) error // AllUserIDs returns all UserIDs regardless of user status or deletion. AllUserIDs(ctx context.Context, includeSystem bool) ([]uuid.UUID, error) ArchiveChatByID(ctx context.Context, id uuid.UUID) ([]Chat, error) @@ -1331,10 +1335,6 @@ type sqlcQuerier interface { // Sets the target queued message's position to one less than the // current minimum position for that chat, moving it to the head. ReorderChatQueuedMessageToHead(ctx context.Context, arg ReorderChatQueuedMessageToHeadParams) (int64, error) - // Resets generation_attempt so the next turn starts with a fresh - // retry budget. The sync_chat_retry_state trigger clears retry_state - // when the attempt value changes. - ResetChatGenerationAttempt(ctx context.Context, id uuid.UUID) error RevokeDBCryptKey(ctx context.Context, activeKeyDigest string) error // Note that this selects from the CTE, not the original table. The CTE is named // the same as the original table to trick sqlc into reusing the existing struct diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 3b41610d9a2..7b8bdcfe94a 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -6565,6 +6565,20 @@ func (q *sqlQuerier) AcquireStaleChatDiffStatuses(ctx context.Context, limitVal return items, nil } +const advanceChatHistoryVersion = `-- name: AdvanceChatHistoryVersion :exec +UPDATE chats +SET history_version = snapshot_version, generation_attempt = 0, updated_at = NOW() +WHERE id = $1::uuid +` + +// Grants a turn that inserts no history the same fresh retry budget +// and message part episode keys a history change would grant. The +// sync_chat_retry_state trigger clears retry_state on the change. +func (q *sqlQuerier) AdvanceChatHistoryVersion(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, advanceChatHistoryVersion, id) + return err +} + const archiveChatByID = `-- name: ArchiveChatByID :many WITH updated_chats AS ( UPDATE chats @@ -10853,21 +10867,6 @@ func (q *sqlQuerier) ReorderChatQueuedMessageToHead(ctx context.Context, arg Reo return result.RowsAffected() } -const resetChatGenerationAttempt = `-- name: ResetChatGenerationAttempt :exec -UPDATE chats -SET generation_attempt = 0, updated_at = NOW() -WHERE id = $1::uuid - AND generation_attempt <> 0 -` - -// Resets generation_attempt so the next turn starts with a fresh -// retry budget. The sync_chat_retry_state trigger clears retry_state -// when the attempt value changes. -func (q *sqlQuerier) ResetChatGenerationAttempt(ctx context.Context, id uuid.UUID) error { - _, err := q.db.ExecContext(ctx, resetChatGenerationAttempt, id) - return err -} - const setChatContextSnapshot = `-- name: SetChatContextSnapshot :exec UPDATE chats SET diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index d3c2e1b8a37..bc9ae14f135 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -2624,14 +2624,13 @@ SET generation_attempt = generation_attempt + 1, updated_at = NOW() WHERE id = @id::uuid RETURNING generation_attempt; --- name: ResetChatGenerationAttempt :exec --- Resets generation_attempt so the next turn starts with a fresh --- retry budget. The sync_chat_retry_state trigger clears retry_state --- when the attempt value changes. +-- name: AdvanceChatHistoryVersion :exec +-- Grants a turn that inserts no history the same fresh retry budget +-- and message part episode keys a history change would grant. The +-- sync_chat_retry_state trigger clears retry_state on the change. UPDATE chats -SET generation_attempt = 0, updated_at = NOW() -WHERE id = @id::uuid - AND generation_attempt <> 0; +SET history_version = snapshot_version, generation_attempt = 0, updated_at = NOW() +WHERE id = @id::uuid; -- name: GetDatabaseNow :one -- Returns the current database timestamp. Used so transitions that diff --git a/coderd/x/chatd/ARCHITECTURE.md b/coderd/x/chatd/ARCHITECTURE.md index 30505abd76d..f65ec15ade5 100644 --- a/coderd/x/chatd/ARCHITECTURE.md +++ b/coderd/x/chatd/ARCHITECTURE.md @@ -116,7 +116,7 @@ I don't recommend reading the rest of section thoroughly if this is your first t - `PromoteQueuedMessage(qid)` makes a queued message the next message to process. It reorders the queue, interrupts active work, cancels pending dynamic-tool action, or promotes into history immediately as required by the input state. - `Interrupt(reason)` requests cancellation of an active generation or closes pending dynamic-tool action. It preserves queued backlog. - `CompleteRequiresAction(results)` inserts submitted tool-result messages followed by any caller-provided suffix messages, clears `requires_action_deadline_at`, and lands in `running`. It preserves queued messages. -- `RequestCompaction` records a manual compaction request on an idle or errored chat by setting `compaction_requested_at` and landing in `running` without inserting any message. It clears `last_error` per the leave-error rule, and resets `generation_attempt` only when the previous turn exhausted the retry budget; sub-cap counters continue forward so message part buffer episode keys are never rewound into the closed-episode retention window. The chat worker picks the chat up like any other running chat and consumes the request. See [Manual compaction](#manual-compaction). +- `RequestCompaction` records a manual compaction request on an idle or errored chat by setting `compaction_requested_at` and landing in `running` without inserting any message. It clears `last_error` per the leave-error rule, advances `history_version` to the transaction's new `snapshot_version`, and resets `generation_attempt`, so the compaction turn gets a full retry budget and message part episode keys that cannot collide with episodes retained from the previous turn. The chat worker picks the chat up like any other running chat and consumes the request. See [Manual compaction](#manual-compaction). ### Transitions used by the chat worker @@ -955,7 +955,7 @@ Compaction reduces the LLM prompt size by summarizing older history into a compr Users can also request a compaction on demand via `POST /api/experimental/chats/{chat}/compact` (surfaced in the web UI as the `/compact` slash command). Manual compaction is a durable one-shot request executed through the normal worker loop rather than synchronously in the HTTP handler. This reuses the worker's lock fencing, retry accounting, streamed "Summarizing..." progress parts, metrics, and debug runs, and it survives replica crashes. The flow: -1. The endpoint applies the `RequestCompaction` transition: allowed from `W`, `E0`, and `E1`, sets `chats.compaction_requested_at = now()`, clears `last_error`, resets `generation_attempt` when the previous turn exhausted the retry budget (the transition inserts no history, so the history-change trigger cannot grant a fresh budget; sub-cap counters continue forward because rewinding would reuse buffered episode keys retained after the failed turn, while at exhaustion the earliest attempts' episodes have long expired), lands in `R0` (or `R1` from `E1`, preserving the queue) without inserting any message, and publishes a status-change pubsub event to wake workers. A timestamp is used instead of a boolean for debuggability. AI Gateway attribution needs no per-request key: generation preparation resolves the owner's synthetic API key like any other turn. +1. The endpoint applies the `RequestCompaction` transition: allowed from `W`, `E0`, and `E1`, it sets `chats.compaction_requested_at = now()`, clears `last_error`, lands in `R0` (or `R1` from `E1`, preserving the queue) without inserting any message, and publishes a status-change pubsub event to wake workers. Because the transition inserts no history, it advances `history_version` to the transaction's new `snapshot_version` and resets `generation_attempt` itself, granting the fresh retry budget and episode keys a history change would otherwise provide. A timestamp is used instead of a boolean for debuggability. AI Gateway attribution needs no per-request key: generation preparation resolves the owner's synthetic API key like any other turn. 2. The generation goroutine's decision logic checks `compaction_requested_at` after the unresolved local/dynamic tool guards but before the history-completeness check (an idle chat's history is otherwise complete, which would end the turn). If the marker is set and at least one uncompressed assistant message exists after the latest compaction boundary, it selects a forced compaction; if there is nothing to compact, the marker is ignored and the turn finishes normally, clearing it. 3. A forced compaction bypasses the automatic threshold gates (usage below threshold, unknown context window, and the threshold=100 disable) and stamps `source: "manual"` instead of `source: "automatic"` into the `chat_summarized` tool call arguments, tool result JSON, and streamed parts so clients can render manual compactions distinctly. 4. The compaction `CommitStep` consumes the request by clearing `compaction_requested_at` in the same transaction that commits the summary triplet. The next decision pass finds the history complete and finishes the turn, so a chat with an empty queue returns to `waiting` with no assistant follow-up; a chat compacted from `E1` proceeds to its queued messages instead. A `post_compact` hook effect is the one exception: because the decision reads user-visible history, an effect that commits a user-visible message leaves the history incomplete and the turn continues with an assistant response. A model-only effect such as `model_context` reaches the model without resuming generation. diff --git a/coderd/x/chatd/chatstate/request_compaction_test.go b/coderd/x/chatd/chatstate/request_compaction_test.go index 48b82eb958d..0238340bea8 100644 --- a/coderd/x/chatd/chatstate/request_compaction_test.go +++ b/coderd/x/chatd/chatstate/request_compaction_test.go @@ -213,22 +213,16 @@ func TestRequestCompaction_ClearedByNewTurn(t *testing.T) { "EditMessage starts a new turn and must clear the marker") } -// TestRequestCompaction_GenerationAttemptBudget verifies the retry -// budget handling: an exhausted counter is reset so the compaction -// turn is not refused its first transient retry, while a sub-cap -// counter continues forward because rewinding would reuse message -// part buffer episode keys still inside the closed-episode retention -// window. -func TestRequestCompaction_GenerationAttemptBudget(t *testing.T) { +func TestRequestCompaction_FreshHistoryEpoch(t *testing.T) { t.Parallel() cases := []struct { - name string - attempts int - wantAttempt int64 + name string + attempts int }{ - {name: "exhausted budget resets", attempts: chatretry.MaxAttempts, wantAttempt: 0}, - {name: "sub-cap budget continues forward", attempts: 3, wantAttempt: 3}, + {name: "unspent budget", attempts: 0}, + {name: "one below the cap", attempts: chatretry.MaxAttempts - 1}, + {name: "exhausted budget", attempts: chatretry.MaxAttempts}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -240,6 +234,7 @@ func TestRequestCompaction_GenerationAttemptBudget(t *testing.T) { _, err := f.DB.IncrementChatGenerationAttempt(ctx, seeded.chatID) require.NoError(t, err) } + before := f.readChat(ctx, t, seeded.chatID) m := chatstate.NewChatMachine(f.DB, f.Pub, seeded.chatID) require.NoError(t, m.Update(ctx, func(tx *chatstate.Tx, store database.Store) error { @@ -248,7 +243,10 @@ func TestRequestCompaction_GenerationAttemptBudget(t *testing.T) { })) chat := f.readChat(ctx, t, seeded.chatID) - require.Equal(t, tc.wantAttempt, chat.GenerationAttempt) + require.Zero(t, chat.GenerationAttempt) + require.Greater(t, chat.HistoryVersion, before.HistoryVersion, + "epoch must advance past every version the previous turn's episode keys used") + require.Equal(t, chat.SnapshotVersion, chat.HistoryVersion) }) } } diff --git a/coderd/x/chatd/chatstate/transitions.go b/coderd/x/chatd/chatstate/transitions.go index 581d1c2cfb2..d6e036a7e69 100644 --- a/coderd/x/chatd/chatstate/transitions.go +++ b/coderd/x/chatd/chatstate/transitions.go @@ -14,7 +14,6 @@ import ( "github.com/coder/coder/v2/coderd/database" coderdpubsub "github.com/coder/coder/v2/coderd/pubsub" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" - "github.com/coder/coder/v2/coderd/x/chatd/chatretry" "github.com/coder/coder/v2/codersdk" ) @@ -683,19 +682,12 @@ type RequestCompactionResult struct { // from its existing running snapshot. Clearing ownership makes // ChatMachine.Update publish an ownership hint for worker acquisition. // -// The generation attempt counter is reset only when the previous turn -// exhausted the retry budget: this transition inserts no history, so -// the history-change triggers cannot grant a fresh budget, and an -// exhausted counter would make the retry gate refuse the compaction -// turn's first transient failure. Sub-cap counters continue forward -// instead of rewinding because message part buffer episodes are keyed -// by (chat, history_version, generation_attempt) and closed episodes -// are retained briefly; a rewind inside that window would collide. -// At exhaustion the rewind is collision-free: retry backoff spacing -// guarantees the earliest attempts' episodes expired long before the -// budget ran out. +// The compaction turn gets the same fresh history epoch a history +// change would grant: a full retry budget regardless of how the +// previous turn spent its own, and message part episode keys that +// cannot collide with episodes the failed turn's replica retains. func (tx *Tx) RequestCompaction(_ RequestCompactionInput) (RequestCompactionResult, error) { - chat, _, err := tx.requireFromAllowed(TransitionRequestCompaction) + _, _, err := tx.requireFromAllowed(TransitionRequestCompaction) if err != nil { return RequestCompactionResult{}, err } @@ -703,10 +695,8 @@ func (tx *Tx) RequestCompaction(_ RequestCompactionInput) (RequestCompactionResu if err != nil { return RequestCompactionResult{}, xerrors.Errorf("get db now: %w", err) } - if chat.GenerationAttempt >= int64(chatretry.MaxAttempts) { - if err := tx.store.ResetChatGenerationAttempt(tx.ctx, tx.chatID); err != nil { - return RequestCompactionResult{}, xerrors.Errorf("reset generation attempt: %w", err) - } + if err := tx.store.AdvanceChatHistoryVersion(tx.ctx, tx.chatID); err != nil { + return RequestCompactionResult{}, xerrors.Errorf("advance history version: %w", err) } updated, err := tx.applyExecutionState(executionStateUpdate{ Status: database.ChatStatusRunning, diff --git a/coderd/x/chatd/chatstate/transitions_matrix_test.go b/coderd/x/chatd/chatstate/transitions_matrix_test.go index 6bb9b858f26..1a7b4a4e020 100644 --- a/coderd/x/chatd/chatstate/transitions_matrix_test.go +++ b/coderd/x/chatd/chatstate/transitions_matrix_test.go @@ -788,8 +788,7 @@ func matrixCases() []transitionCaseSpec { editMessageCase(chatstate.StateA0), editMessageCase(chatstate.StateA1), - // RequestCompaction: sets the one-shot marker, clears - // last_error, and mutates no history or queue. + // RequestCompaction cases. requestCompactionCase(chatstate.StateW, chatstate.StateR0), requestCompactionCase(chatstate.StateE0, chatstate.StateR0), requestCompactionCase(chatstate.StateE1, chatstate.StateR1), @@ -1449,6 +1448,12 @@ func requestCompactionCase(from, want chatstate.ExecutionState) transitionCaseSp "RequestCompaction sets compaction_requested_at") require.False(t, after.LastError.Valid, "RequestCompaction clears last_error") + require.Greater(t, after.HistoryVersion, base.historyVersion, + "RequestCompaction starts a fresh history epoch") + require.Equal(t, after.SnapshotVersion, after.HistoryVersion, + "RequestCompaction advances history_version to snapshot_version") + require.Zero(t, after.GenerationAttempt, + "RequestCompaction grants a fresh retry budget") require.Equal(t, base.historyIDs, activeHistoryIDs(ctx, t, f, seeded.chatID), "RequestCompaction inserts no history messages") require.Equal(t, base.queueIDs, queuedIDsByPosition(ctx, t, f, seeded.chatID), From 4c9a59b38c5e4cfa32404443e53c81cd7f5cb019 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:48:56 +0000 Subject: [PATCH 5/7] refactor: fold the compaction history epoch grant into UpdateChatExecutionState A standalone AdvanceChatHistoryVersion query exposed a general store method that can move history_version without any history change. Make the epoch grant an option on the execution-state update instead, so it rides the same atomic UPDATE that clears last_error and sets the compaction marker, and only RequestCompaction can reach it. --- coderd/database/dbauthz/dbauthz.go | 11 ---------- coderd/database/dbauthz/dbauthz_test.go | 6 ------ coderd/database/dbmetrics/querymetrics.go | 8 -------- coderd/database/dbmock/dbmock.go | 14 ------------- coderd/database/querier.go | 8 ++++---- coderd/database/queries.sql.go | 25 +++++++++-------------- coderd/database/queries/chats.sql | 15 +++++++------- coderd/x/chatd/chatstate/transitions.go | 9 +++++--- 8 files changed, 27 insertions(+), 69 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index fd2eee57756..3f68893f59b 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1767,17 +1767,6 @@ func (q *querier) ActivityBumpWorkspace(ctx context.Context, arg database.Activi return update(q.log, q.auth, fetch, q.db.ActivityBumpWorkspace)(ctx, arg) } -func (q *querier) AdvanceChatHistoryVersion(ctx context.Context, id uuid.UUID) error { - chat, err := q.db.GetChatByID(ctx, id) - if err != nil { - return err - } - if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { - return err - } - return q.db.AdvanceChatHistoryVersion(ctx, id) -} - func (q *querier) AllUserIDs(ctx context.Context, includeSystem bool) ([]uuid.UUID, error) { // Although this technically only reads users, only system-related functions // should be allowed to call this. diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 5e02f092740..a1dd4f79731 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1269,12 +1269,6 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().IncrementChatGenerationAttempt(gomock.Any(), chat.ID).Return(int64(7), nil).AnyTimes() check.Args(chat.ID).Asserts(chat, policy.ActionUpdate).Returns(int64(7)) })) - s.Run("AdvanceChatHistoryVersion", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { - chat := testutil.Fake(s.T(), faker, database.Chat{}) - dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() - dbm.EXPECT().AdvanceChatHistoryVersion(gomock.Any(), chat.ID).Return(nil).AnyTimes() - check.Args(chat.ID).Asserts(chat, policy.ActionUpdate) - })) s.Run("UpdateChatRetryState", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { chat := testutil.Fake(s.T(), faker, database.Chat{}) arg := database.UpdateChatRetryStateParams{ID: chat.ID, RetryState: []byte(`{"attempt":1}`)} diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index c2eb046ccab..f27c4271dbe 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -145,14 +145,6 @@ func (m queryMetricsStore) ActivityBumpWorkspace(ctx context.Context, arg databa return r0 } -func (m queryMetricsStore) AdvanceChatHistoryVersion(ctx context.Context, id uuid.UUID) error { - start := time.Now() - r0 := m.s.AdvanceChatHistoryVersion(ctx, id) - m.queryLatencies.WithLabelValues("AdvanceChatHistoryVersion").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "AdvanceChatHistoryVersion").Inc() - return r0 -} - func (m queryMetricsStore) AllUserIDs(ctx context.Context, includeSystem bool) ([]uuid.UUID, error) { start := time.Now() r0, r1 := m.s.AllUserIDs(ctx, includeSystem) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 07d80ab5e42..f172027dad5 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -118,20 +118,6 @@ func (mr *MockStoreMockRecorder) ActivityBumpWorkspace(ctx, arg any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ActivityBumpWorkspace", reflect.TypeOf((*MockStore)(nil).ActivityBumpWorkspace), ctx, arg) } -// AdvanceChatHistoryVersion mocks base method. -func (m *MockStore) AdvanceChatHistoryVersion(ctx context.Context, id uuid.UUID) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "AdvanceChatHistoryVersion", ctx, id) - ret0, _ := ret[0].(error) - return ret0 -} - -// AdvanceChatHistoryVersion indicates an expected call of AdvanceChatHistoryVersion. -func (mr *MockStoreMockRecorder) AdvanceChatHistoryVersion(ctx, id any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AdvanceChatHistoryVersion", reflect.TypeOf((*MockStore)(nil).AdvanceChatHistoryVersion), ctx, id) -} - // AllUserIDs mocks base method. func (m *MockStore) AllUserIDs(ctx context.Context, includeSystem bool) ([]uuid.UUID, error) { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 288c5abe98d..ac0bb988327 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -50,10 +50,6 @@ type sqlcQuerier interface { // We only bump if workspace shutdown is manual. // We only bump when 5% of the deadline has elapsed. ActivityBumpWorkspace(ctx context.Context, arg ActivityBumpWorkspaceParams) error - // Grants a turn that inserts no history the same fresh retry budget - // and message part episode keys a history change would grant. The - // sync_chat_retry_state trigger clears retry_state on the change. - AdvanceChatHistoryVersion(ctx context.Context, id uuid.UUID) error // AllUserIDs returns all UserIDs regardless of user status or deletion. AllUserIDs(ctx context.Context, includeSystem bool) ([]uuid.UUID, error) ArchiveChatByID(ctx context.Context, id uuid.UUID) ([]Chat, error) @@ -1450,6 +1446,10 @@ type sqlcQuerier interface { // requires-action deadline, and the manual compaction request marker. // Callers compose this with transition mutations inside a single // ChatMachine.Update transaction. + // + // grant_history_epoch gives a turn that inserts no history the same + // fresh retry budget and message part episode keys a history change + // would grant, mirroring the chat_messages trigger postcondition. UpdateChatExecutionState(ctx context.Context, arg UpdateChatExecutionStateParams) (Chat, error) // Bumps the heartbeat timestamp for the given set of chat IDs, // provided they are still running and owned by the specified diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 7b8bdcfe94a..f1346dde620 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -6565,20 +6565,6 @@ func (q *sqlQuerier) AcquireStaleChatDiffStatuses(ctx context.Context, limitVal return items, nil } -const advanceChatHistoryVersion = `-- name: AdvanceChatHistoryVersion :exec -UPDATE chats -SET history_version = snapshot_version, generation_attempt = 0, updated_at = NOW() -WHERE id = $1::uuid -` - -// Grants a turn that inserts no history the same fresh retry budget -// and message part episode keys a history change would grant. The -// sync_chat_retry_state trigger clears retry_state on the change. -func (q *sqlQuerier) AdvanceChatHistoryVersion(ctx context.Context, id uuid.UUID) error { - _, err := q.db.ExecContext(ctx, advanceChatHistoryVersion, id) - return err -} - const archiveChatByID = `-- name: ArchiveChatByID :many WITH updated_chats AS ( UPDATE chats @@ -11428,9 +11414,12 @@ WITH updated_chat AS ( last_error = $5::jsonb, requires_action_deadline_at = $6::timestamptz, compaction_requested_at = $7::timestamptz, + history_version = CASE WHEN $8::boolean THEN snapshot_version ELSE history_version END, + generation_attempt = CASE WHEN $8::boolean THEN 0 ELSE generation_attempt END, + retry_state = CASE WHEN $8::boolean THEN NULL ELSE retry_state END, pin_order = CASE WHEN $2::boolean THEN 0 ELSE pin_order END, updated_at = NOW() - WHERE id = $8::uuid + WHERE id = $9::uuid RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, last_reasoning_effort, compaction_requested_at, summary, summary_generated_at ), chats_expanded AS ( @@ -11498,6 +11487,7 @@ type UpdateChatExecutionStateParams struct { LastError pqtype.NullRawMessage `db:"last_error" json:"last_error"` RequiresActionDeadlineAt sql.NullTime `db:"requires_action_deadline_at" json:"requires_action_deadline_at"` CompactionRequestedAt sql.NullTime `db:"compaction_requested_at" json:"compaction_requested_at"` + GrantHistoryEpoch bool `db:"grant_history_epoch" json:"grant_history_epoch"` ID uuid.UUID `db:"id" json:"id"` } @@ -11506,6 +11496,10 @@ type UpdateChatExecutionStateParams struct { // requires-action deadline, and the manual compaction request marker. // Callers compose this with transition mutations inside a single // ChatMachine.Update transaction. +// +// grant_history_epoch gives a turn that inserts no history the same +// fresh retry budget and message part episode keys a history change +// would grant, mirroring the chat_messages trigger postcondition. func (q *sqlQuerier) UpdateChatExecutionState(ctx context.Context, arg UpdateChatExecutionStateParams) (Chat, error) { row := q.db.QueryRowContext(ctx, updateChatExecutionState, arg.Status, @@ -11515,6 +11509,7 @@ func (q *sqlQuerier) UpdateChatExecutionState(ctx context.Context, arg UpdateCha arg.LastError, arg.RequiresActionDeadlineAt, arg.CompactionRequestedAt, + arg.GrantHistoryEpoch, arg.ID, ) var i Chat diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index bc9ae14f135..7cf3efab69f 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -2479,6 +2479,10 @@ FROM chats_expanded; -- requires-action deadline, and the manual compaction request marker. -- Callers compose this with transition mutations inside a single -- ChatMachine.Update transaction. +-- +-- grant_history_epoch gives a turn that inserts no history the same +-- fresh retry budget and message part episode keys a history change +-- would grant, mirroring the chat_messages trigger postcondition. WITH updated_chat AS ( UPDATE chats SET @@ -2489,6 +2493,9 @@ WITH updated_chat AS ( last_error = sqlc.narg('last_error')::jsonb, requires_action_deadline_at = sqlc.narg('requires_action_deadline_at')::timestamptz, compaction_requested_at = sqlc.narg('compaction_requested_at')::timestamptz, + history_version = CASE WHEN @grant_history_epoch::boolean THEN snapshot_version ELSE history_version END, + generation_attempt = CASE WHEN @grant_history_epoch::boolean THEN 0 ELSE generation_attempt END, + retry_state = CASE WHEN @grant_history_epoch::boolean THEN NULL ELSE retry_state END, pin_order = CASE WHEN @archived::boolean THEN 0 ELSE pin_order END, updated_at = NOW() WHERE id = @id::uuid @@ -2624,14 +2631,6 @@ SET generation_attempt = generation_attempt + 1, updated_at = NOW() WHERE id = @id::uuid RETURNING generation_attempt; --- name: AdvanceChatHistoryVersion :exec --- Grants a turn that inserts no history the same fresh retry budget --- and message part episode keys a history change would grant. The --- sync_chat_retry_state trigger clears retry_state on the change. -UPDATE chats -SET history_version = snapshot_version, generation_attempt = 0, updated_at = NOW() -WHERE id = @id::uuid; - -- name: GetDatabaseNow :one -- Returns the current database timestamp. Used so transitions that -- record deadlines or heartbeats rely on a clock that is consistent diff --git a/coderd/x/chatd/chatstate/transitions.go b/coderd/x/chatd/chatstate/transitions.go index d6e036a7e69..fb43e0e6ade 100644 --- a/coderd/x/chatd/chatstate/transitions.go +++ b/coderd/x/chatd/chatstate/transitions.go @@ -183,6 +183,10 @@ type executionStateUpdate struct { LastError pqtype.NullRawMessage RequiresActionDeadlineAt sql.NullTime CompactionRequestedAt sql.NullTime + // GrantHistoryEpoch gives a turn that inserts no history the same + // fresh retry budget and message part episode keys a history + // change would grant. + GrantHistoryEpoch bool } func (tx *Tx) applyExecutionState(u executionStateUpdate) (database.Chat, error) { @@ -195,6 +199,7 @@ func (tx *Tx) applyExecutionState(u executionStateUpdate) (database.Chat, error) LastError: u.LastError, RequiresActionDeadlineAt: u.RequiresActionDeadlineAt, CompactionRequestedAt: u.CompactionRequestedAt, + GrantHistoryEpoch: u.GrantHistoryEpoch, }) } @@ -695,9 +700,6 @@ func (tx *Tx) RequestCompaction(_ RequestCompactionInput) (RequestCompactionResu if err != nil { return RequestCompactionResult{}, xerrors.Errorf("get db now: %w", err) } - if err := tx.store.AdvanceChatHistoryVersion(tx.ctx, tx.chatID); err != nil { - return RequestCompactionResult{}, xerrors.Errorf("advance history version: %w", err) - } updated, err := tx.applyExecutionState(executionStateUpdate{ Status: database.ChatStatusRunning, Archived: false, @@ -706,6 +708,7 @@ func (tx *Tx) RequestCompaction(_ RequestCompactionInput) (RequestCompactionResu LastError: pqtype.NullRawMessage{}, RequiresActionDeadlineAt: sql.NullTime{}, CompactionRequestedAt: sql.NullTime{Time: now, Valid: true}, + GrantHistoryEpoch: true, }) if err != nil { return RequestCompactionResult{}, xerrors.Errorf("set running: %w", err) From 7e5af55137421da4a9396ac0a12ed30843db7709 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:54:46 +0000 Subject: [PATCH 6/7] chore: comment cleanup --- coderd/x/chatd/chatstate/transitions.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/coderd/x/chatd/chatstate/transitions.go b/coderd/x/chatd/chatstate/transitions.go index fb43e0e6ade..227ba2f1628 100644 --- a/coderd/x/chatd/chatstate/transitions.go +++ b/coderd/x/chatd/chatstate/transitions.go @@ -183,10 +183,7 @@ type executionStateUpdate struct { LastError pqtype.NullRawMessage RequiresActionDeadlineAt sql.NullTime CompactionRequestedAt sql.NullTime - // GrantHistoryEpoch gives a turn that inserts no history the same - // fresh retry budget and message part episode keys a history - // change would grant. - GrantHistoryEpoch bool + GrantHistoryEpoch bool } func (tx *Tx) applyExecutionState(u executionStateUpdate) (database.Chat, error) { From c8ecce9ead11b6214aa486e128a268b1325fe738 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:01:31 +0000 Subject: [PATCH 7/7] test: cover stale retry_state clearing in the fresh history epoch At attempt 0 the sync_chat_retry_state trigger cannot clear a stale retry payload because generation_attempt does not change, so only the explicit clear in the epoch grant covers it. --- coderd/x/chatd/chatstate/request_compaction_test.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/coderd/x/chatd/chatstate/request_compaction_test.go b/coderd/x/chatd/chatstate/request_compaction_test.go index 0238340bea8..63a2761375c 100644 --- a/coderd/x/chatd/chatstate/request_compaction_test.go +++ b/coderd/x/chatd/chatstate/request_compaction_test.go @@ -234,6 +234,11 @@ func TestRequestCompaction_FreshHistoryEpoch(t *testing.T) { _, err := f.DB.IncrementChatGenerationAttempt(ctx, seeded.chatID) require.NoError(t, err) } + _, err := f.DB.UpdateChatRetryState(ctx, database.UpdateChatRetryStateParams{ + ID: seeded.chatID, + RetryState: []byte(`{"attempt":1}`), + }) + require.NoError(t, err) before := f.readChat(ctx, t, seeded.chatID) m := chatstate.NewChatMachine(f.DB, f.Pub, seeded.chatID) @@ -247,6 +252,8 @@ func TestRequestCompaction_FreshHistoryEpoch(t *testing.T) { require.Greater(t, chat.HistoryVersion, before.HistoryVersion, "epoch must advance past every version the previous turn's episode keys used") require.Equal(t, chat.SnapshotVersion, chat.HistoryVersion) + require.False(t, chat.RetryState.Valid, + "a stale retry payload must not survive into the fresh epoch, even at attempt 0 where the generation_attempt trigger cannot clear it") }) } }