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/database/querier.go b/coderd/database/querier.go index a7f52a88464..ac0bb988327 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1446,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 1682eb894a0..f1346dde620 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -11414,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 ( @@ -11484,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"` } @@ -11492,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, @@ -11501,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 290e0c17f9d..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 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..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 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, 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 @@ -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`, 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 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..63a2761375c 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,14 +213,59 @@ func TestRequestCompaction_ClearedByNewTurn(t *testing.T) { "EditMessage starts a new turn and must clear the marker") } +func TestRequestCompaction_FreshHistoryEpoch(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + attempts int + }{ + {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) { + 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) + } + _, 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) + 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) + 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") + }) + } +} + // 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..227ba2f1628 100644 --- a/coderd/x/chatd/chatstate/transitions.go +++ b/coderd/x/chatd/chatstate/transitions.go @@ -183,6 +183,7 @@ type executionStateUpdate struct { LastError pqtype.NullRawMessage RequiresActionDeadlineAt sql.NullTime CompactionRequestedAt sql.NullTime + GrantHistoryEpoch bool } func (tx *Tx) applyExecutionState(u executionStateUpdate) (database.Chat, error) { @@ -195,6 +196,7 @@ func (tx *Tx) applyExecutionState(u executionStateUpdate) (database.Chat, error) LastError: u.LastError, RequiresActionDeadlineAt: u.RequiresActionDeadlineAt, CompactionRequestedAt: u.CompactionRequestedAt, + GrantHistoryEpoch: u.GrantHistoryEpoch, }) } @@ -676,12 +678,18 @@ 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. +// +// 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 } @@ -694,9 +702,10 @@ 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}, + GrantHistoryEpoch: true, }) if err != nil { return RequestCompactionResult{}, xerrors.Errorf("set running: %w", err) diff --git a/coderd/x/chatd/chatstate/transitions_matrix_test.go b/coderd/x/chatd/chatstate/transitions_matrix_test.go index 15258c53c26..1a7b4a4e020 100644 --- a/coderd/x/chatd/chatstate/transitions_matrix_test.go +++ b/coderd/x/chatd/chatstate/transitions_matrix_test.go @@ -788,9 +788,10 @@ 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 cases. + 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 +1433,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 +1446,14 @@ 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.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), 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(