Files
coder/coderd/x/chatd/tasks_test.go
T
Hugo Dutka 96130e2bc5 chore(coderd/x/chatd): address generation review items (#26517)
Addresses the deferred `coderd/x/chatd/generation.go` review comments
from PR #26109: [required generation
dependencies](https://github.com/coder/coder/pull/26109#discussion_r3380311853),
[scoped chat
variables](https://github.com/coder/coder/pull/26109#discussion_r3387161874),
[generation state error
handling](https://github.com/coder/coder/pull/26109#discussion_r3387191468),
[generation attempt return
values](https://github.com/coder/coder/pull/26109#discussion_r3387251382),
[generation fence
verification](https://github.com/coder/coder/pull/26109#discussion_r3387288234),
and [chatdebug outcome
recording](https://github.com/coder/coder/pull/26109#discussion_r3387544273).

This makes generation task dependencies explicit, packages generation
attempt episode state into a struct, and centralizes generation task
fence checks for generation transitions.

Generated by Coder Agents, closely reviewed by Hugo.
2026-07-06 16:37:45 +00:00

1263 lines
44 KiB
Go

//nolint:testpackage // These tests exercise package-private task seams.
package chatd
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub"
coderdpubsub "github.com/coder/coder/v2/coderd/pubsub"
"github.com/coder/coder/v2/coderd/x/chatd/chaterror"
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
"github.com/coder/coder/v2/coderd/x/chatd/chatretry"
"github.com/coder/coder/v2/coderd/x/chatd/chatstate"
"github.com/coder/coder/v2/coderd/x/chatd/messagepartbuffer"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
"github.com/coder/quartz"
)
func TestRetryWrapper_ExpectedExitsDoNotRetry(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
sink := testutil.NewFakeSink(t)
calls := 0
err := runTaskWithRetry(ctx, retryWrapperOptions{
clock: quartz.NewMock(t),
logger: sink.Logger(),
initialDelay: time.Second,
maxDelay: time.Second,
}, taskKindInterrupt, retryWrapperTaskInfo{}, func(context.Context) error {
calls++
return errTaskExpectedExit
})
require.NoError(t, err)
require.Equal(t, 1, calls)
require.Empty(t, entriesWithMessage(sink, "chatworker task retrying"))
}
func TestRetryWrapper_UnexpectedErrorsRetry(t *testing.T) {
t.Parallel()
clock := quartz.NewMock(t)
trap := clock.Trap().NewTimer("chatworker", "task-retry-requires_action_timeout")
defer trap.Close()
ctx := testutil.Context(t, testutil.WaitLong)
sink := testutil.NewFakeSink(t)
calls := 0
done := make(chan error, 1)
go func() {
done <- runTaskWithRetry(ctx, retryWrapperOptions{
clock: clock,
logger: sink.Logger(),
initialDelay: time.Minute,
maxDelay: time.Minute,
}, taskKindRequiresActionTimeout, retryWrapperTaskInfo{}, func(context.Context) error {
calls++
if calls == 1 {
return xerrors.New("database unavailable")
}
return nil
})
}()
trap.MustWait(ctx).MustRelease(ctx)
clock.Advance(time.Minute).MustWait(ctx)
require.NoError(t, <-done)
require.Equal(t, 2, calls)
entries := entriesWithMessage(sink, "chatworker task retrying")
require.Len(t, entries, 1)
require.Equal(t, string(taskKindRequiresActionTimeout), sinkFieldValue(t, entries[0].Fields, "task_kind"))
require.Equal(t, time.Minute.String(), sinkFieldValue(t, entries[0].Fields, "delay"))
require.Contains(t, sinkFieldValue(t, entries[0].Fields, "error"), "database unavailable")
}
func TestRetryWrapper_PanicsRetry(t *testing.T) {
t.Parallel()
clock := quartz.NewMock(t)
trap := clock.Trap().NewTimer("chatworker", "task-retry-generation")
defer trap.Close()
ctx := testutil.Context(t, testutil.WaitLong)
sink := testutil.NewFakeSink(t)
calls := 0
done := make(chan error, 1)
go func() {
done <- runTaskWithRetry(ctx, retryWrapperOptions{
clock: clock,
logger: sink.Logger(),
initialDelay: time.Minute,
maxDelay: time.Minute,
}, taskKindGeneration, retryWrapperTaskInfo{}, func(context.Context) error {
calls++
if calls == 1 {
panic("database unavailable")
}
return nil
})
}()
trap.MustWait(ctx).MustRelease(ctx)
clock.Advance(time.Minute).MustWait(ctx)
require.NoError(t, <-done)
require.Equal(t, 2, calls)
entries := entriesWithMessage(sink, "chatworker task retrying")
require.Len(t, entries, 1)
require.Contains(t, sinkFieldValue(t, entries[0].Fields, "error"), "chatworker task panic: database unavailable")
}
// database/sql returns ctx.Err() from ctxDriverQuery, not
// context.Cause(ctx). This test checks that the retry logic
// doesn't classify such an error as an expected exit when
// task timeout is the cause of the cancellation.
func TestRetryWrapper_TaskTimeoutDBQueryCancellationRetries(t *testing.T) {
t.Parallel()
f := newTaskTestFixture(t)
clock := quartz.NewMock(t)
timeoutTrap := clock.Trap().AfterFunc("chatworker", "task-timeout-generation")
retryTrap := clock.Trap().NewTimer("chatworker", "task-retry-generation")
defer retryTrap.Close()
ctx := testutil.Context(t, testutil.WaitLong)
sink := testutil.NewFakeSink(t)
calls := 0
firstCallStarted := make(chan struct{})
var firstQueryErr error
var firstQueryCause error
done := make(chan error, 1)
go func() {
done <- runTaskWithRetry(ctx, retryWrapperOptions{
clock: clock,
logger: sink.Logger(),
initialDelay: time.Minute,
maxDelay: time.Minute,
}, taskKindGeneration, retryWrapperTaskInfo{}, func(ctx context.Context) error {
calls++
if calls == 1 {
close(firstCallStarted)
<-ctx.Done()
_, err := f.db.GetDatabaseNow(ctx)
firstQueryErr = err
firstQueryCause = context.Cause(ctx)
return normalizeTaskTransitionError(err, "db query")
}
return nil
})
}()
timeoutTrap.MustWait(ctx).MustRelease(ctx)
timeoutTrap.Close()
<-firstCallStarted
clock.Advance(defaultTaskTimeout).MustWait(ctx)
retryTrap.MustWait(ctx).MustRelease(ctx)
clock.Advance(time.Minute).MustWait(ctx)
require.NoError(t, <-done)
require.Equal(t, 2, calls)
require.ErrorIs(t, firstQueryErr, context.Canceled)
require.NotErrorIs(t, firstQueryErr, errTaskTimeout)
require.ErrorIs(t, firstQueryCause, errTaskTimeout)
entries := entriesWithMessage(sink, "chatworker task retrying")
require.Len(t, entries, 1)
require.Contains(t, sinkFieldValue(t, entries[0].Fields, "error"), errTaskTimeout.Error())
require.Contains(t, sinkFieldValue(t, entries[0].Fields, "error"), context.Canceled.Error())
}
func TestRetryWrapper_ContextCancellationDoesNotRetryOrLog(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(testutil.Context(t, testutil.WaitLong))
cancel()
sink := testutil.NewFakeSink(t)
calls := 0
original := xerrors.New("database unavailable")
err := runTaskWithRetry(ctx, retryWrapperOptions{
clock: quartz.NewMock(t),
logger: sink.Logger(),
initialDelay: time.Second,
maxDelay: time.Second,
}, taskKindGeneration, retryWrapperTaskInfo{}, func(context.Context) error {
calls++
return original
})
require.NoError(t, err)
require.Equal(t, 1, calls)
require.Empty(t, entriesWithMessage(sink, "chatworker task retrying"))
}
func TestNormalizeTaskErrors_ContextCancellationIsExpectedExit(t *testing.T) {
t.Parallel()
err := normalizeTaskInfrastructureError(context.Canceled, "lock chat")
require.ErrorIs(t, err, errTaskExpectedExit)
require.ErrorIs(t, err, context.Canceled)
require.NotErrorIs(t, err, errTaskRetryable)
require.NotErrorIs(t, err, errTaskTimeout)
err = normalizeTaskTransitionError(context.Canceled, "commit chat")
require.ErrorIs(t, err, errTaskExpectedExit)
require.ErrorIs(t, err, context.Canceled)
require.NotErrorIs(t, err, errTaskRetryable)
require.NotErrorIs(t, err, errTaskTimeout)
}
func entriesWithMessage(sink *testutil.FakeSink, message string) []slog.SinkEntry {
return sink.Entries(func(e slog.SinkEntry) bool { return e.Message == message })
}
func sinkFieldValue(t *testing.T, fields slog.Map, name string) string {
t.Helper()
for _, f := range fields {
if f.Name == name {
return fmt.Sprint(f.Value)
}
}
t.Fatalf("missing log field %q", name)
return ""
}
func TestInterruptTask_FinishInterruptionOnly(t *testing.T) {
t.Parallel()
f := newTaskTestFixture(t)
chat := f.createRunningChat(t)
workerID := uuid.New()
runnerID := uuid.New()
acquired := f.acquireChat(t, chat.ID, workerID, runnerID)
recorder := newTaskSideEffectRecorder()
starter := newTestTaskStarter(t, f, recorder)
buffer := starter.opts.MessagePartBuffer
key := messagepartbuffer.Key{
ChatID: chat.ID,
HistoryVersion: acquired.HistoryVersion,
GenerationAttempt: acquired.GenerationAttempt,
}
require.NoError(t, buffer.CreateEpisode(key))
require.NoError(t, buffer.AddPart(key, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessageText("partial answer")))
interrupting := f.interruptChat(t, chat.ID)
require.Equal(t, database.ChatStatusInterrupting, interrupting.Status)
err := starter.StartInterrupt(testutil.Context(t, testutil.WaitLong), chatWorkerTaskStartInput{
ChatID: chat.ID,
WorkerID: workerID,
RunnerID: runnerID,
HistoryVersion: interrupting.HistoryVersion,
GenerationAttempt: interrupting.GenerationAttempt,
Status: database.ChatStatusInterrupting,
})
require.NoError(t, err)
latest, err := f.db.GetChatByID(testutil.Context(t, testutil.WaitShort), chat.ID)
require.NoError(t, err)
require.Equal(t, database.ChatStatusRunning, latest.Status)
recorder.requireStateHint(t, chat.ID, latest.SnapshotVersion, database.ChatStatusRunning)
recorder.requireInterruptionOutcome(t, chat.ID, database.ChatStatusRunning)
recorder.requireCleanupCount(t, 0)
f.requireWatchEvent(t, chat.ID, codersdk.ChatWatchEventKindStatusChange)
messages, err := f.db.GetChatMessagesByChatID(testutil.Context(t, testutil.WaitShort), database.GetChatMessagesByChatIDParams{ChatID: chat.ID})
require.NoError(t, err)
require.GreaterOrEqual(t, len(messages), 3)
parts, err := chatprompt.ParseContent(messages[len(messages)-2])
require.NoError(t, err)
require.Equal(t, []codersdk.ChatMessagePart{codersdk.ChatMessageText("partial answer")}, parts)
require.Equal(t, database.ChatMessageRoleUser, messages[len(messages)-1].Role)
}
func TestInterruptTask_StaleFenceExits(t *testing.T) {
t.Parallel()
f := newTaskTestFixture(t)
chat := f.createRunningChat(t)
workerID := uuid.New()
runnerID := uuid.New()
f.acquireChat(t, chat.ID, workerID, runnerID)
interrupting := f.interruptChat(t, chat.ID)
otherWorkerID := uuid.New()
otherRunnerID := uuid.New()
f.acquireChat(t, chat.ID, otherWorkerID, otherRunnerID)
recorder := newTaskSideEffectRecorder()
starter := newTestTaskStarter(t, f, recorder)
err := starter.StartInterrupt(testutil.Context(t, testutil.WaitLong), chatWorkerTaskStartInput{
ChatID: chat.ID,
WorkerID: workerID,
RunnerID: runnerID,
HistoryVersion: interrupting.HistoryVersion,
GenerationAttempt: interrupting.GenerationAttempt,
Status: database.ChatStatusInterrupting,
})
require.ErrorIs(t, err, errTaskExpectedExit)
latest, err := f.db.GetChatByID(testutil.Context(t, testutil.WaitShort), chat.ID)
require.NoError(t, err)
require.Equal(t, database.ChatStatusInterrupting, latest.Status)
require.Equal(t, otherWorkerID, latest.WorkerID.UUID)
require.Equal(t, otherRunnerID, latest.RunnerID.UUID)
recorder.requireStateHintCount(t, 0)
f.requireNoWatchEvents(t)
}
func TestInterruptTask_MissingEpisodePersistsNilPartials(t *testing.T) {
t.Parallel()
f := newTaskTestFixture(t)
chat := f.createRunningChat(t)
workerID := uuid.New()
runnerID := uuid.New()
f.acquireChat(t, chat.ID, workerID, runnerID)
interrupting := f.forceExecutionState(t, chat.ID, database.ChatStatusInterrupting, false, sql.NullTime{})
recorder := newTaskSideEffectRecorder()
starter := newTestTaskStarter(t, f, recorder)
err := starter.StartInterrupt(testutil.Context(t, testutil.WaitLong), chatWorkerTaskStartInput{
ChatID: chat.ID,
WorkerID: workerID,
RunnerID: runnerID,
HistoryVersion: interrupting.HistoryVersion,
GenerationAttempt: interrupting.GenerationAttempt,
Status: database.ChatStatusInterrupting,
})
require.NoError(t, err)
latest, err := f.db.GetChatByID(testutil.Context(t, testutil.WaitShort), chat.ID)
require.NoError(t, err)
require.Equal(t, database.ChatStatusWaiting, latest.Status)
recorder.requireInterruptionOutcome(t, chat.ID, database.ChatStatusWaiting)
messages, err := f.db.GetChatMessagesByChatID(testutil.Context(t, testutil.WaitShort), database.GetChatMessagesByChatIDParams{ChatID: chat.ID})
require.NoError(t, err)
require.Len(t, messages, 1)
recorder.requireStateHint(t, chat.ID, latest.SnapshotVersion, database.ChatStatusWaiting)
}
func TestInterruptTask_BufferedPartsBecomePartialMessages(t *testing.T) {
t.Parallel()
f := newTaskTestFixture(t)
chat := f.createRunningChat(t)
workerID := uuid.New()
runnerID := uuid.New()
acquired := f.acquireChat(t, chat.ID, workerID, runnerID)
recorder := newTaskSideEffectRecorder()
starter := newTestTaskStarter(t, f, recorder)
buffer := starter.opts.MessagePartBuffer
key := messagepartbuffer.Key{ChatID: chat.ID, HistoryVersion: acquired.HistoryVersion, GenerationAttempt: acquired.GenerationAttempt}
require.NoError(t, buffer.CreateEpisode(key))
callID := "call_" + uuid.NewString()
require.NoError(t, buffer.AddPart(key, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeToolCall,
ToolCallID: callID,
ToolName: "local_tool",
Args: json.RawMessage(`{"value":1}`),
}))
interrupting := f.interruptChat(t, chat.ID)
err := starter.StartInterrupt(testutil.Context(t, testutil.WaitLong), chatWorkerTaskStartInput{
ChatID: chat.ID,
WorkerID: workerID,
RunnerID: runnerID,
HistoryVersion: interrupting.HistoryVersion,
GenerationAttempt: interrupting.GenerationAttempt,
Status: database.ChatStatusInterrupting,
})
require.NoError(t, err)
messages, err := f.db.GetChatMessagesByChatID(testutil.Context(t, testutil.WaitShort), database.GetChatMessagesByChatIDParams{ChatID: chat.ID})
require.NoError(t, err)
require.GreaterOrEqual(t, len(messages), 4)
assistant := messages[len(messages)-3]
tool := messages[len(messages)-2]
require.Equal(t, database.ChatMessageRoleAssistant, assistant.Role)
require.Equal(t, database.ChatMessageRoleTool, tool.Role)
toolParts, err := chatprompt.ParseContent(tool)
require.NoError(t, err)
require.Len(t, toolParts, 1)
require.Equal(t, codersdk.ChatMessagePartTypeToolResult, toolParts[0].Type)
require.Equal(t, callID, toolParts[0].ToolCallID)
require.True(t, toolParts[0].IsError)
}
func TestRequiresActionTimeout_ExpiredCancelsOnly(t *testing.T) {
t.Parallel()
f := newTaskTestFixture(t)
chat := f.createRequiresActionChat(t)
workerID := uuid.New()
runnerID := uuid.New()
acquired := f.acquireChat(t, chat.ID, workerID, runnerID)
expired := f.setRequiresActionDeadline(t, chat.ID, sql.NullTime{Time: time.Now().Add(-time.Minute), Valid: true})
recorder := newTaskSideEffectRecorder()
starter := newTestTaskStarter(t, f, recorder)
err := starter.StartRequiresActionTimeout(testutil.Context(t, testutil.WaitLong), chatWorkerTaskStartInput{
ChatID: chat.ID,
WorkerID: workerID,
RunnerID: runnerID,
HistoryVersion: acquired.HistoryVersion,
Status: database.ChatStatusRequiresAction,
RequiresActionDeadlineAt: expired.RequiresActionDeadlineAt,
})
require.NoError(t, err)
latest, err := f.db.GetChatByID(testutil.Context(t, testutil.WaitShort), chat.ID)
require.NoError(t, err)
require.Equal(t, database.ChatStatusRunning, latest.Status)
require.False(t, latest.RequiresActionDeadlineAt.Valid)
recorder.requireStateHint(t, chat.ID, latest.SnapshotVersion, database.ChatStatusRunning)
f.requireWatchEvent(t, chat.ID, codersdk.ChatWatchEventKindStatusChange)
}
func TestRequiresActionTimeout_NullDeadlineCancelsImmediately(t *testing.T) {
t.Parallel()
f := newTaskTestFixture(t)
chat := f.createRequiresActionChat(t)
workerID := uuid.New()
runnerID := uuid.New()
acquired := f.acquireChat(t, chat.ID, workerID, runnerID)
nullDeadline := f.setRequiresActionDeadline(t, chat.ID, sql.NullTime{})
recorder := newTaskSideEffectRecorder()
starter := newTestTaskStarter(t, f, recorder)
err := starter.StartRequiresActionTimeout(testutil.Context(t, testutil.WaitLong), chatWorkerTaskStartInput{
ChatID: chat.ID,
WorkerID: workerID,
RunnerID: runnerID,
HistoryVersion: acquired.HistoryVersion,
Status: database.ChatStatusRequiresAction,
RequiresActionDeadlineAt: nullDeadline.RequiresActionDeadlineAt,
})
require.NoError(t, err)
latest, err := f.db.GetChatByID(testutil.Context(t, testutil.WaitShort), chat.ID)
require.NoError(t, err)
require.Equal(t, database.ChatStatusRunning, latest.Status)
recorder.requireStateHint(t, chat.ID, latest.SnapshotVersion, database.ChatStatusRunning)
}
func TestRequiresActionTimeout_StaleFenceExitsAfterToolResult(t *testing.T) {
t.Parallel()
f := newTaskTestFixture(t)
chat := f.createRequiresActionChat(t)
workerID := uuid.New()
runnerID := uuid.New()
acquired := f.acquireChat(t, chat.ID, workerID, runnerID)
expired := f.setRequiresActionDeadline(t, chat.ID, sql.NullTime{Time: time.Now().Add(-time.Minute), Valid: true})
f.forceExecutionState(t, chat.ID, database.ChatStatusRunning, false, sql.NullTime{})
recorder := newTaskSideEffectRecorder()
starter := newTestTaskStarter(t, f, recorder)
err := starter.StartRequiresActionTimeout(testutil.Context(t, testutil.WaitLong), chatWorkerTaskStartInput{
ChatID: chat.ID,
WorkerID: workerID,
RunnerID: runnerID,
HistoryVersion: acquired.HistoryVersion,
Status: database.ChatStatusRequiresAction,
RequiresActionDeadlineAt: expired.RequiresActionDeadlineAt,
})
require.ErrorIs(t, err, errTaskExpectedExit)
latest, err := f.db.GetChatByID(testutil.Context(t, testutil.WaitShort), chat.ID)
require.NoError(t, err)
require.Equal(t, database.ChatStatusRunning, latest.Status)
recorder.requireStateHintCount(t, 0)
f.requireNoWatchEvents(t)
}
func TestAbandonTask_AbandonOnly(t *testing.T) {
t.Parallel()
f := newTaskTestFixture(t)
chat := f.createRunningChat(t)
workerID := uuid.New()
runnerID := uuid.New()
acquired := f.acquireChat(t, chat.ID, workerID, runnerID)
recorder := newTaskSideEffectRecorder()
starter := newTestTaskStarter(t, f, recorder)
err := starter.StartAbandon(testutil.Context(t, testutil.WaitLong), chatWorkerTaskStartInput{
ChatID: chat.ID,
WorkerID: workerID,
RunnerID: runnerID,
HistoryVersion: acquired.HistoryVersion,
Status: database.ChatStatusRunning,
})
require.NoError(t, err)
latest, err := f.db.GetChatByID(testutil.Context(t, testutil.WaitShort), chat.ID)
require.NoError(t, err)
require.False(t, latest.WorkerID.Valid)
require.False(t, latest.RunnerID.Valid)
recorder.requireCleanup(t, chat.ID, runnerID)
recorder.requireStateHintCount(t, 0)
f.requireNoWatchEvents(t)
}
func TestAbandonTask_OwnershipMismatchRequestsCleanup(t *testing.T) {
t.Parallel()
f := newTaskTestFixture(t)
chat := f.createRunningChat(t)
workerID := uuid.New()
runnerID := uuid.New()
f.acquireChat(t, chat.ID, workerID, runnerID)
otherWorkerID := uuid.New()
otherRunnerID := uuid.New()
latestOwner := f.acquireChat(t, chat.ID, otherWorkerID, otherRunnerID)
recorder := newTaskSideEffectRecorder()
starter := newTestTaskStarter(t, f, recorder)
err := starter.StartAbandon(testutil.Context(t, testutil.WaitLong), chatWorkerTaskStartInput{
ChatID: chat.ID,
WorkerID: workerID,
RunnerID: runnerID,
HistoryVersion: latestOwner.HistoryVersion,
Status: database.ChatStatusRunning,
})
require.NoError(t, err)
latest, err := f.db.GetChatByID(testutil.Context(t, testutil.WaitShort), chat.ID)
require.NoError(t, err)
require.Equal(t, otherWorkerID, latest.WorkerID.UUID)
require.Equal(t, otherRunnerID, latest.RunnerID.UUID)
recorder.requireCleanup(t, chat.ID, runnerID)
}
func TestAbandonTask_StaleStatusFenceExits(t *testing.T) {
t.Parallel()
f := newTaskTestFixture(t)
chat := f.createRunningChat(t)
workerID := uuid.New()
runnerID := uuid.New()
acquired := f.acquireChat(t, chat.ID, workerID, runnerID)
f.forceExecutionState(t, chat.ID, database.ChatStatusInterrupting, false, sql.NullTime{})
recorder := newTaskSideEffectRecorder()
starter := newTestTaskStarter(t, f, recorder)
err := starter.StartAbandon(testutil.Context(t, testutil.WaitLong), chatWorkerTaskStartInput{
ChatID: chat.ID,
WorkerID: workerID,
RunnerID: runnerID,
HistoryVersion: acquired.HistoryVersion,
Status: database.ChatStatusWaiting,
})
require.ErrorIs(t, err, errTaskExpectedExit)
latest, err := f.db.GetChatByID(testutil.Context(t, testutil.WaitShort), chat.ID)
require.NoError(t, err)
require.True(t, latest.WorkerID.Valid)
require.True(t, latest.RunnerID.Valid)
require.Equal(t, database.ChatStatusInterrupting, latest.Status)
recorder.requireCleanupCount(t, 0)
}
func TestGenerationTask_RecordRetryState(t *testing.T) {
t.Parallel()
f := newTaskTestFixture(t)
chat := f.createRunningChat(t)
workerID := uuid.New()
runnerID := uuid.New()
acquired := f.acquireChat(t, chat.ID, workerID, runnerID)
recorder := newTaskSideEffectRecorder()
starter := newTestTaskStarter(t, f, recorder)
attempt, err := starter.beginGenerationAttempt(
testutil.Context(t, testutil.WaitLong),
chatstate.NewChatMachine(f.db, f.pubsub, chat.ID),
chatWorkerTaskStartInput{
ChatID: chat.ID,
WorkerID: workerID,
RunnerID: runnerID,
HistoryVersion: acquired.HistoryVersion,
Status: database.ChatStatusRunning,
},
)
require.NoError(t, err)
attempt.closeEpisode()
require.Equal(t, int64(1), attempt.number)
before, err := f.db.GetChatByID(testutil.Context(t, testutil.WaitShort), chat.ID)
require.NoError(t, err)
require.False(t, before.RetryState.Valid)
decision, err := starter.recordGenerationRetry(
testutil.Context(t, testutil.WaitLong),
chatstate.NewChatMachine(f.db, f.pubsub, chat.ID),
chatWorkerTaskStartInput{
ChatID: chat.ID,
WorkerID: workerID,
RunnerID: runnerID,
HistoryVersion: acquired.HistoryVersion,
Status: database.ChatStatusRunning,
},
chaterror.ClassifiedError{
Message: "OpenAI is rate limiting requests.",
Kind: codersdk.ChatErrorKindRateLimit,
Provider: "openai",
Retryable: true,
StatusCode: 429,
},
)
require.NoError(t, err)
require.True(t, decision.retry)
require.Equal(t, int64(1), decision.generationAttempt)
require.Equal(t, chatretry.Delay(0), decision.delay)
latest, err := f.db.GetChatByID(testutil.Context(t, testutil.WaitShort), chat.ID)
require.NoError(t, err)
require.True(t, latest.RetryState.Valid)
require.Equal(t, latest.SnapshotVersion, latest.RetryStateVersion)
require.Greater(t, latest.RetryStateVersion, before.RetryStateVersion)
require.Equal(t, before.GenerationAttempt, latest.GenerationAttempt)
recorder.requireStateHintCount(t, 0)
var retryPayload codersdk.ChatStreamRetry
require.NoError(t, json.Unmarshal(latest.RetryState.RawMessage, &retryPayload))
require.Equal(t, 1, retryPayload.Attempt)
require.Equal(t, chatretry.Delay(0).Milliseconds(), retryPayload.DelayMs)
require.Equal(t, "OpenAI is rate limiting requests.", retryPayload.Error)
require.Equal(t, codersdk.ChatErrorKindRateLimit, retryPayload.Kind)
require.Equal(t, "openai", retryPayload.Provider)
require.Equal(t, 429, retryPayload.StatusCode)
require.False(t, retryPayload.RetryingAt.IsZero())
}
func TestGenerationTask_RecordRetryStateUsesDurableGenerationAttempt(t *testing.T) {
t.Parallel()
f := newTaskTestFixture(t)
chat := f.createRunningChat(t)
workerID := uuid.New()
runnerID := uuid.New()
acquired := f.acquireChat(t, chat.ID, workerID, runnerID)
starter := newTestTaskStarter(t, f, newTaskSideEffectRecorder())
machine := chatstate.NewChatMachine(f.db, f.pubsub, chat.ID)
for range 3 {
attempt, err := starter.beginGenerationAttempt(
testutil.Context(t, testutil.WaitLong),
machine,
chatWorkerTaskStartInput{
ChatID: chat.ID,
WorkerID: workerID,
RunnerID: runnerID,
HistoryVersion: acquired.HistoryVersion,
Status: database.ChatStatusRunning,
},
)
require.NoError(t, err)
attempt.closeEpisode()
require.Positive(t, attempt.number)
}
decision, err := starter.recordGenerationRetry(
testutil.Context(t, testutil.WaitLong),
machine,
chatWorkerTaskStartInput{
ChatID: chat.ID,
WorkerID: workerID,
RunnerID: runnerID,
HistoryVersion: acquired.HistoryVersion,
Status: database.ChatStatusRunning,
},
chaterror.ClassifiedError{
Message: "OpenAI is temporarily unavailable.",
Kind: codersdk.ChatErrorKindTimeout,
Provider: "openai",
Retryable: true,
},
)
require.NoError(t, err)
require.True(t, decision.retry)
require.Equal(t, int64(3), decision.generationAttempt)
require.Equal(t, chatretry.Delay(2), decision.delay)
latest, err := f.db.GetChatByID(testutil.Context(t, testutil.WaitShort), chat.ID)
require.NoError(t, err)
var retryPayload codersdk.ChatStreamRetry
require.NoError(t, json.Unmarshal(latest.RetryState.RawMessage, &retryPayload))
require.Equal(t, 3, retryPayload.Attempt)
require.Equal(t, chatretry.Delay(2).Milliseconds(), retryPayload.DelayMs)
}
func TestGenerationTask_RecordRetryStateClearedByNextAttempt(t *testing.T) {
t.Parallel()
f := newTaskTestFixture(t)
chat := f.createRunningChat(t)
workerID := uuid.New()
runnerID := uuid.New()
acquired := f.acquireChat(t, chat.ID, workerID, runnerID)
starter := newTestTaskStarter(t, f, newTaskSideEffectRecorder())
machine := chatstate.NewChatMachine(f.db, f.pubsub, chat.ID)
input := chatWorkerTaskStartInput{
ChatID: chat.ID,
WorkerID: workerID,
RunnerID: runnerID,
HistoryVersion: acquired.HistoryVersion,
Status: database.ChatStatusRunning,
}
attempt, err := starter.beginGenerationAttempt(testutil.Context(t, testutil.WaitLong), machine, input)
require.NoError(t, err)
attempt.closeEpisode()
require.Equal(t, int64(1), attempt.number)
_, err = starter.recordGenerationRetry(
testutil.Context(t, testutil.WaitLong),
machine,
input,
chaterror.ClassifiedError{
Message: "OpenAI is temporarily unavailable.",
Kind: codersdk.ChatErrorKindTimeout,
Provider: "openai",
Retryable: true,
},
)
require.NoError(t, err)
withRetry, err := f.db.GetChatByID(testutil.Context(t, testutil.WaitShort), chat.ID)
require.NoError(t, err)
require.True(t, withRetry.RetryState.Valid)
attempt, err = starter.beginGenerationAttempt(testutil.Context(t, testutil.WaitLong), machine, input)
require.NoError(t, err)
attempt.closeEpisode()
require.Equal(t, int64(2), attempt.number)
after, err := f.db.GetChatByID(testutil.Context(t, testutil.WaitShort), chat.ID)
require.NoError(t, err)
require.False(t, after.RetryState.Valid)
require.Equal(t, after.SnapshotVersion, after.RetryStateVersion)
require.Greater(t, after.RetryStateVersion, withRetry.RetryStateVersion)
}
func TestGenerationTask_RecordRetryStateStaleFenceExits(t *testing.T) {
t.Parallel()
f := newTaskTestFixture(t)
chat := f.createRunningChat(t)
workerID := uuid.New()
runnerID := uuid.New()
acquired := f.acquireChat(t, chat.ID, workerID, runnerID)
starter := newTestTaskStarter(t, f, newTaskSideEffectRecorder())
machine := chatstate.NewChatMachine(f.db, f.pubsub, chat.ID)
attempt, err := starter.beginGenerationAttempt(
testutil.Context(t, testutil.WaitLong),
machine,
chatWorkerTaskStartInput{
ChatID: chat.ID,
WorkerID: workerID,
RunnerID: runnerID,
HistoryVersion: acquired.HistoryVersion,
Status: database.ChatStatusRunning,
},
)
require.NoError(t, err)
attempt.closeEpisode()
require.Equal(t, int64(1), attempt.number)
otherWorkerID := uuid.New()
otherRunnerID := uuid.New()
f.acquireChat(t, chat.ID, otherWorkerID, otherRunnerID)
_, err = starter.recordGenerationRetry(
testutil.Context(t, testutil.WaitLong),
machine,
chatWorkerTaskStartInput{
ChatID: chat.ID,
WorkerID: workerID,
RunnerID: runnerID,
HistoryVersion: acquired.HistoryVersion,
Status: database.ChatStatusRunning,
},
chaterror.ClassifiedError{
Message: "OpenAI is temporarily unavailable.",
Kind: codersdk.ChatErrorKindTimeout,
Provider: "openai",
Retryable: true,
},
)
require.ErrorIs(t, err, errTaskExpectedExit)
latest, err := f.db.GetChatByID(testutil.Context(t, testutil.WaitShort), chat.ID)
require.NoError(t, err)
require.False(t, latest.RetryState.Valid)
require.Equal(t, otherWorkerID, latest.WorkerID.UUID)
require.Equal(t, otherRunnerID, latest.RunnerID.UUID)
}
func TestRunner_StartsRealInterruptTask(t *testing.T) {
t.Parallel()
f := newTaskTestFixture(t)
chat := f.createRunningChat(t)
worker := startRealTaskWorker(t, f)
waitOwnedChat(t, f, chat.ID, worker.chatWorkerID())
interrupting := f.interruptChat(t, chat.ID)
require.Equal(t, database.ChatStatusInterrupting, interrupting.Status)
testutil.Eventually(testutil.Context(t, testutil.WaitLong), t, func(ctx context.Context) bool {
latest, err := f.db.GetChatByID(ctx, chat.ID)
return err == nil && latest.Status == database.ChatStatusRunning
}, testutil.IntervalFast)
latest, err := f.db.GetChatByID(testutil.Context(t, testutil.WaitShort), chat.ID)
require.NoError(t, err)
require.Equal(t, worker.chatWorkerID(), latest.WorkerID.UUID)
f.requireWatchEvent(t, chat.ID, codersdk.ChatWatchEventKindStatusChange)
}
func TestRunner_StartsRealRequiresActionTimeoutTask(t *testing.T) {
t.Parallel()
f := newTaskTestFixture(t)
chat := f.createRequiresActionChat(t)
f.setRequiresActionDeadline(t, chat.ID, sql.NullTime{Time: time.Now().Add(-time.Minute), Valid: true})
worker := startRealTaskWorker(t, f)
testutil.Eventually(testutil.Context(t, testutil.WaitLong), t, func(ctx context.Context) bool {
latest, err := f.db.GetChatByID(ctx, chat.ID)
return err == nil && latest.Status == database.ChatStatusRunning && latest.WorkerID.Valid && latest.WorkerID.UUID == worker.chatWorkerID()
}, testutil.IntervalFast)
latest, err := f.db.GetChatByID(testutil.Context(t, testutil.WaitShort), chat.ID)
require.NoError(t, err)
require.True(t, latest.RunnerID.Valid)
f.requireWatchEvent(t, chat.ID, codersdk.ChatWatchEventKindStatusChange)
}
func TestRunner_StartsRealAbandonTask(t *testing.T) {
t.Parallel()
f := newTaskTestFixture(t)
chat := f.createRunningChat(t)
worker := startRealTaskWorker(t, f)
waitOwnedChat(t, f, chat.ID, worker.chatWorkerID())
updated := f.forceExecutionState(t, chat.ID, database.ChatStatusError, false, sql.NullTime{})
f.publishChatUpdate(t, updated)
testutil.Eventually(testutil.Context(t, testutil.WaitLong), t, func(ctx context.Context) bool {
latest, err := f.db.GetChatByID(ctx, chat.ID)
return err == nil && !latest.WorkerID.Valid && !latest.RunnerID.Valid
}, testutil.IntervalFast)
}
type taskTestFixture struct {
db database.Store
pubsub *taskRecordingPubsub
rawPS dbpubsub.Pubsub
sqlDB *sql.DB
user database.User
org database.Organization
model database.ChatModelConfig
apiKey database.APIKey
}
func newTaskTestFixture(t *testing.T) *taskTestFixture {
t.Helper()
db, ps, sqlDB := dbtestutil.NewDBWithSQLDB(t)
user := dbgen.User(t, db, database.User{})
org := dbgen.Organization(t, db, database.Organization{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: user.ID, OrganizationID: org.ID})
dbgen.ChatProvider(t, db, database.ChatProvider{
Provider: "openai",
DisplayName: "openai",
BaseUrl: "http://example.invalid",
})
model := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{IsDefault: true})
apiKey, _ := dbgen.APIKey(t, db, database.APIKey{UserID: user.ID})
return &taskTestFixture{db: db, pubsub: newTaskRecordingPubsub(ps), rawPS: ps, sqlDB: sqlDB, user: user, org: org, model: model, apiKey: apiKey}
}
func (f *taskTestFixture) createRunningChat(t *testing.T) database.Chat {
t.Helper()
res, err := chatstate.CreateChat(testutil.Context(t, testutil.WaitShort), f.db, f.pubsub, chatstate.CreateChatInput{
OrganizationID: f.org.ID,
OwnerID: f.user.ID,
LastModelConfigID: f.model.ID,
Title: "test",
ClientType: database.ChatClientTypeApi,
InitialMessages: []chatstate.Message{taskUserTextMessage(t, "hello", f.user.ID, f.model.ID, f.apiKey.ID)},
})
require.NoError(t, err)
f.pubsub.clear()
return res.Chat
}
func (f *taskTestFixture) createRequiresActionChat(t *testing.T) database.Chat {
t.Helper()
toolName := "dynamic_" + uuid.NewString()
dynamicTools, err := json.Marshal([]codersdk.DynamicTool{{
Name: toolName,
Description: "test tool",
InputSchema: json.RawMessage(`{"type":"object"}`),
}})
require.NoError(t, err)
res, err := chatstate.CreateChat(testutil.Context(t, testutil.WaitShort), f.db, f.pubsub, chatstate.CreateChatInput{
OrganizationID: f.org.ID,
OwnerID: f.user.ID,
LastModelConfigID: f.model.ID,
Title: "test",
ClientType: database.ChatClientTypeApi,
DynamicTools: pqtype.NullRawMessage{RawMessage: dynamicTools, Valid: true},
InitialMessages: []chatstate.Message{taskUserTextMessage(t, "hello", f.user.ID, f.model.ID, f.apiKey.ID)},
})
require.NoError(t, err)
machine := chatstate.NewChatMachine(f.db, f.pubsub, res.Chat.ID)
require.NoError(t, machine.Update(testutil.Context(t, testutil.WaitShort), func(tx *chatstate.Tx, store database.Store) error {
_, err := tx.CommitStep(chatstate.CommitStepInput{Messages: []chatstate.Message{taskAssistantToolCallMessage(t, f.model.ID, toolName)}})
return err
}))
require.NoError(t, machine.Update(testutil.Context(t, testutil.WaitShort), func(tx *chatstate.Tx, store database.Store) error {
_, err := tx.EnterRequiresAction(chatstate.EnterRequiresActionInput{})
return err
}))
chat, err := f.db.GetChatByID(testutil.Context(t, testutil.WaitShort), res.Chat.ID)
require.NoError(t, err)
f.pubsub.clear()
return chat
}
func (f *taskTestFixture) acquireChat(t *testing.T, chatID uuid.UUID, workerID uuid.UUID, runnerID uuid.UUID) database.Chat {
t.Helper()
machine := chatstate.NewChatMachine(f.db, f.pubsub, chatID)
require.NoError(t, machine.Update(testutil.Context(t, testutil.WaitShort), func(tx *chatstate.Tx, store database.Store) error {
_, err := tx.Acquire(chatstate.AcquireInput{WorkerID: workerID, RunnerID: runnerID})
return err
}))
chat, err := f.db.GetChatByID(testutil.Context(t, testutil.WaitShort), chatID)
require.NoError(t, err)
f.pubsub.clear()
return chat
}
func (f *taskTestFixture) interruptChat(t *testing.T, chatID uuid.UUID) database.Chat {
t.Helper()
machine := chatstate.NewChatMachine(f.db, f.pubsub, chatID)
require.NoError(t, machine.Update(testutil.Context(t, testutil.WaitShort), func(tx *chatstate.Tx, store database.Store) error {
_, err := tx.SendMessage(chatstate.SendMessageInput{
Message: taskUserTextMessage(t, "interrupt", f.user.ID, f.model.ID, f.apiKey.ID),
BusyBehavior: chatstate.BusyBehaviorInterrupt,
})
return err
}))
chat, err := f.db.GetChatByID(testutil.Context(t, testutil.WaitShort), chatID)
require.NoError(t, err)
f.pubsub.clear()
return chat
}
func (f *taskTestFixture) forceExecutionState(t *testing.T, chatID uuid.UUID, status database.ChatStatus, archived bool, deadline sql.NullTime) database.Chat {
t.Helper()
var updated database.Chat
require.NoError(t, f.db.InTx(func(store database.Store) error {
if _, err := store.LockChatAndBumpSnapshotVersion(testutil.Context(t, testutil.WaitShort), chatID); err != nil {
return err
}
chat, err := store.GetChatByID(testutil.Context(t, testutil.WaitShort), chatID)
if err != nil {
return err
}
updated, err = store.UpdateChatExecutionState(testutil.Context(t, testutil.WaitShort), database.UpdateChatExecutionStateParams{
ID: chat.ID,
Status: status,
Archived: archived,
WorkerID: chat.WorkerID,
RunnerID: chat.RunnerID,
LastError: chat.LastError,
RequiresActionDeadlineAt: deadline,
})
return err
}, nil))
f.pubsub.clear()
return updated
}
func (f *taskTestFixture) setRequiresActionDeadline(t *testing.T, chatID uuid.UUID, deadline sql.NullTime) database.Chat {
t.Helper()
chat, err := f.db.GetChatByID(testutil.Context(t, testutil.WaitShort), chatID)
require.NoError(t, err)
return f.forceExecutionState(t, chatID, chat.Status, chat.Archived, deadline)
}
func (f *taskTestFixture) publishChatUpdate(t *testing.T, chat database.Chat) {
t.Helper()
msg := coderdpubsub.ChatStateUpdateMessage{
SnapshotVersion: chat.SnapshotVersion,
HistoryVersion: chat.HistoryVersion,
QueueVersion: chat.QueueVersion,
RetryStateVersion: chat.RetryStateVersion,
GenerationAttempt: chat.GenerationAttempt,
Status: string(chat.Status),
Archived: chat.Archived,
}
if chat.WorkerID.Valid {
id := chat.WorkerID.UUID
msg.WorkerID = &id
}
if chat.RunnerID.Valid {
id := chat.RunnerID.UUID
msg.RunnerID = &id
}
payload, err := json.Marshal(msg)
require.NoError(t, err)
require.NoError(t, f.pubsub.Publish(coderdpubsub.ChatStateUpdateChannel(chat.ID), payload))
}
func (f *taskTestFixture) requireWatchEvent(t *testing.T, chatID uuid.UUID, kind codersdk.ChatWatchEventKind) {
t.Helper()
// Watch events are published after the corresponding database update
// commits, so poll instead of asserting on a single snapshot.
testutil.Eventually(testutil.Context(t, testutil.WaitLong), t, func(_ context.Context) bool {
for _, event := range f.pubsub.watchEvents(t) {
if event.Kind == kind && event.Chat.ID == chatID {
return true
}
}
return false
}, testutil.IntervalFast)
}
func (f *taskTestFixture) requireNoWatchEvents(t *testing.T) {
t.Helper()
require.Empty(t, f.pubsub.watchEvents(t))
}
func taskUserTextMessage(t *testing.T, text string, createdBy uuid.UUID, modelConfigID uuid.UUID, apiKeyID string) chatstate.Message {
t.Helper()
raw, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{codersdk.ChatMessageText(text)})
require.NoError(t, err)
return chatstate.Message{
Role: database.ChatMessageRoleUser,
Content: raw,
Visibility: database.ChatMessageVisibilityBoth,
ContentVersion: chatprompt.CurrentContentVersion,
CreatedBy: uuid.NullUUID{UUID: createdBy, Valid: true},
ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: true},
APIKeyID: sql.NullString{String: apiKeyID, Valid: apiKeyID != ""},
}
}
func taskAssistantToolCallMessage(t *testing.T, modelConfigID uuid.UUID, toolName string) chatstate.Message {
t.Helper()
raw, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{{
Type: codersdk.ChatMessagePartTypeToolCall,
ToolCallID: "call_" + uuid.NewString(),
ToolName: toolName,
Args: json.RawMessage(`{}`),
}})
require.NoError(t, err)
return chatstate.Message{
Role: database.ChatMessageRoleAssistant,
Content: raw,
Visibility: database.ChatMessageVisibilityBoth,
ContentVersion: chatprompt.CurrentContentVersion,
ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: true},
}
}
type taskPublishedEvent struct {
channel string
payload []byte
}
type taskRecordingPubsub struct {
inner dbpubsub.Pubsub
mu sync.Mutex
sent []taskPublishedEvent
}
func newTaskRecordingPubsub(inner dbpubsub.Pubsub) *taskRecordingPubsub {
return &taskRecordingPubsub{inner: inner}
}
func (p *taskRecordingPubsub) Publish(channel string, payload []byte) error {
p.mu.Lock()
p.sent = append(p.sent, taskPublishedEvent{channel: channel, payload: append([]byte(nil), payload...)})
p.mu.Unlock()
return p.inner.Publish(channel, payload)
}
func (p *taskRecordingPubsub) SubscribeWithErr(channel string, listener dbpubsub.ListenerWithErr) (func(), error) {
return p.inner.SubscribeWithErr(channel, listener)
}
func (p *taskRecordingPubsub) clear() {
p.mu.Lock()
p.sent = nil
p.mu.Unlock()
}
func (p *taskRecordingPubsub) events() []taskPublishedEvent {
p.mu.Lock()
defer p.mu.Unlock()
return append([]taskPublishedEvent(nil), p.sent...)
}
func (p *taskRecordingPubsub) watchEvents(t *testing.T) []codersdk.ChatWatchEvent {
t.Helper()
events := p.events()
out := make([]codersdk.ChatWatchEvent, 0)
for _, event := range events {
var payload codersdk.ChatWatchEvent
if err := json.Unmarshal(event.payload, &payload); err != nil {
continue
}
if event.channel != coderdpubsub.ChatWatchEventChannel(payload.Chat.OwnerID) {
continue
}
out = append(out, payload)
}
return out
}
func startRealTaskWorker(t *testing.T, f *taskTestFixture) *chatWorker {
t.Helper()
buffer := messagepartbuffer.New(messagepartbuffer.Options{})
t.Cleanup(buffer.Close)
worker, err := newChatWorker(newUnstartedServer(t, f.rawPS, f.db), chatWorkerOptions{
WorkerID: uuid.New(),
Store: f.db,
Pubsub: f.pubsub,
Logger: slog.Make(),
MessagePartBuffer: buffer,
AcquisitionInterval: time.Hour,
AcquisitionBatchSize: 10,
RunnerSyncInterval: time.Hour,
HeartbeatInterval: time.Hour,
HeartbeatCleanupInterval: time.Hour,
HeartbeatStaleSeconds: 30,
StateChannelSize: 16,
RunnerManagerChannelSize: 16,
AcquisitionWakeChannelSize: 1,
TaskRetryInitialBackoff: time.Millisecond,
TaskRetryMaxBackoff: time.Millisecond,
})
require.NoError(t, err)
require.NoError(t, worker.Start(context.Background()))
t.Cleanup(func() { require.NoError(t, worker.Close()) })
return worker
}
func waitOwnedChat(t *testing.T, f *taskTestFixture, chatID uuid.UUID, workerID uuid.UUID) database.Chat {
t.Helper()
var latest database.Chat
testutil.Eventually(testutil.Context(t, testutil.WaitLong), t, func(ctx context.Context) bool {
chat, err := f.db.GetChatByID(ctx, chatID)
if err != nil {
return false
}
latest = chat
return chat.WorkerID.Valid && chat.WorkerID.UUID == workerID && chat.RunnerID.Valid
}, testutil.IntervalFast)
return latest
}
type taskSideEffectRecorder struct {
mu sync.Mutex
hints []runnerStateUpdate
cleanups []runnerKey
interrupts []interruptionOutcome
}
func newTaskSideEffectRecorder() *taskSideEffectRecorder {
return &taskSideEffectRecorder{}
}
func (r *taskSideEffectRecorder) routeStateHint(_ context.Context, state runnerStateUpdate) {
r.mu.Lock()
r.hints = append(r.hints, state)
r.mu.Unlock()
}
func (r *taskSideEffectRecorder) requestCleanup(_ context.Context, key runnerKey) {
r.mu.Lock()
r.cleanups = append(r.cleanups, key)
r.mu.Unlock()
}
func (r *taskSideEffectRecorder) afterInterruptionOutcome(_ context.Context, outcome interruptionOutcome) error {
r.mu.Lock()
r.interrupts = append(r.interrupts, outcome)
r.mu.Unlock()
return nil
}
func (r *taskSideEffectRecorder) requireStateHint(t *testing.T, chatID uuid.UUID, snapshot int64, status database.ChatStatus) {
t.Helper()
r.mu.Lock()
defer r.mu.Unlock()
for _, hint := range r.hints {
if hint.ChatID == chatID && hint.SnapshotVersion == snapshot && hint.Status == status {
return
}
}
t.Fatalf("missing state hint chat_id=%s snapshot=%d status=%s hints=%v", chatID, snapshot, status, r.hints)
}
func (r *taskSideEffectRecorder) requireStateHintCount(t *testing.T, count int) {
t.Helper()
r.mu.Lock()
defer r.mu.Unlock()
require.Len(t, r.hints, count)
}
func (r *taskSideEffectRecorder) requireCleanup(t *testing.T, chatID uuid.UUID, runnerID uuid.UUID) {
t.Helper()
r.mu.Lock()
defer r.mu.Unlock()
for _, cleanup := range r.cleanups {
if cleanup.ChatID == chatID && cleanup.RunnerID == runnerID {
return
}
}
t.Fatalf("missing cleanup chat_id=%s runner_id=%s cleanups=%v", chatID, runnerID, r.cleanups)
}
func (r *taskSideEffectRecorder) requireCleanupCount(t *testing.T, count int) {
t.Helper()
r.mu.Lock()
defer r.mu.Unlock()
require.Len(t, r.cleanups, count)
}
func (r *taskSideEffectRecorder) requireInterruptionOutcome(t *testing.T, chatID uuid.UUID, status database.ChatStatus) {
t.Helper()
r.mu.Lock()
defer r.mu.Unlock()
for _, outcome := range r.interrupts {
if outcome.Chat.ID == chatID && outcome.Chat.Status == status {
return
}
}
t.Fatalf("missing interruption outcome chat_id=%s status=%s outcomes=%v", chatID, status, r.interrupts)
}
func newTestTaskStarter(t *testing.T, f *taskTestFixture, recorder *taskSideEffectRecorder) *taskStarter {
t.Helper()
buffer := messagepartbuffer.New(messagepartbuffer.Options{})
t.Cleanup(buffer.Close)
starter, err := newTaskStarter(newUnstartedServer(t, f.rawPS, f.db), chatWorkerOptions{
Store: f.db,
Pubsub: f.pubsub,
Logger: slog.Make(),
Clock: quartz.NewReal(),
MessagePartBuffer: buffer,
TaskRetryInitialBackoff: time.Millisecond,
TaskRetryMaxBackoff: time.Millisecond,
}, recorder.routeStateHint, recorder.requestCleanup)
require.NoError(t, err)
starter.afterInterruptionOutcome = recorder.afterInterruptionOutcome
return starter
}