fix: allow manual chat compaction from the error state (#28022)

A chat that fails generation with a context overflow (for example `Input
length 262625 exceeds the maximum allowed input length of 262112
tokens`) is stuck in a catch-22: `POST /chats/{id}/compact` returns 409
because the `RequestCompaction` transition is only allowed from the
waiting state, and the only other way out of the error state is sending
or editing a message, which re-runs generation with the same oversized
prompt and fails again. Compaction is exactly the recovery a
context-overflowed chat needs, and it is unreachable exactly when it is
needed.

Three semantic changes:

- Allow `RequestCompaction` from the error states: `E0 -> R0` and `E1 ->
R1` (queued messages are preserved and processed after the compaction
turn).
- Clear `last_error` in `Tx.RequestCompaction`, matching the
architecture rule that transitions leaving `E0`/`E1` clear the stored
error. Without this a successful compaction would land in waiting with a
stale persisted error.
- Grant the compaction turn a fresh history epoch: a
`grant_history_epoch` flag on `UpdateChatExecutionState` sets
`history_version = snapshot_version`, resets `generation_attempt`, and
clears `retry_state` in the same atomic update that clears `last_error`
(mirroring the `chat_messages` trigger postcondition). The transition
inserts no history, so without this the turn inherits the failed turn's
spent retry budget, and resetting the counter alone could collide with
message part episode keys still retained on the erroring replica.

No frontend change is required: the chat input is already enabled in the
error state and `/compact` submission already handles both the success
and 409 paths. Also updates ARCHITECTURE.md (transition matrix,
endpoint, and manual compaction sections), the endpoint's swagger
description, and SDK comments.

> Mux created this PR on Mike's behalf.

<!-- mux-attribution: model=claude-sonnet-4-6 thinking=high -->
This commit is contained in:
Michael Suchacz
2026-08-12 20:38:57 +02:00
committed by GitHub
parent b145142404
commit 1458d27d78
17 changed files with 226 additions and 48 deletions
+1 -1
View File
@@ -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"
],
+1 -1
View File
@@ -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",
+4
View File
@@ -1432,6 +1432,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
+10 -1
View File
@@ -11420,9 +11420,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 (
@@ -11490,6 +11493,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"`
}
@@ -11498,6 +11502,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,
@@ -11507,6 +11515,7 @@ func (q *sqlQuerier) UpdateChatExecutionState(ctx context.Context, arg UpdateCha
arg.LastError,
arg.RequiresActionDeadlineAt,
arg.CompactionRequestedAt,
arg.GrantHistoryEpoch,
arg.ID,
)
var i Chat
+7
View File
@@ -2470,6 +2470,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
@@ -2480,6 +2484,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
+5 -7
View File
@@ -3388,9 +3388,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)
@@ -3431,12 +3432,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))
+30
View File
@@ -9307,6 +9307,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()
@@ -9328,6 +9357,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) {
+8 -4
View File
@@ -118,7 +118,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
@@ -156,10 +156,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
@@ -551,8 +553,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
@@ -953,10 +957,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.
+7 -7
View File
@@ -2288,16 +2288,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(
+59
View File
@@ -5639,6 +5639,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()
@@ -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()
+5 -3
View File
@@ -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},
+15 -6
View File
@@ -188,6 +188,7 @@ type executionStateUpdate struct {
LastError pqtype.NullRawMessage
RequiresActionDeadlineAt sql.NullTime
CompactionRequestedAt sql.NullTime
GrantHistoryEpoch bool
}
func (tx *Tx) applyExecutionState(u executionStateUpdate) (database.Chat, error) {
@@ -200,6 +201,7 @@ func (tx *Tx) applyExecutionState(u executionStateUpdate) (database.Chat, error)
LastError: u.LastError,
RequiresActionDeadlineAt: u.RequiresActionDeadlineAt,
CompactionRequestedAt: u.CompactionRequestedAt,
GrantHistoryEpoch: u.GrantHistoryEpoch,
})
}
@@ -681,12 +683,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
}
@@ -699,9 +707,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)
@@ -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),
+4 -4
View File
@@ -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 {
+3 -2
View File
@@ -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
+3 -3
View File
@@ -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<TypesGen.Chat> => {
const response = await this.axios.post<TypesGen.Chat>(