Files
kandev/apps/backend/internal/orchestrator/ceiling_surface_test.go
T
2026-09-17 23:46:57 +01:00

181 lines
8.7 KiB
Go

package orchestrator
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/kandev/kandev/internal/task/models"
)
// TestDeferCeilingRefusal_WritesCardNoteOnFirstRefusal pins AC-49: a
// session-bearing refusal writes the card note inline and stamps
// ceiling_surface_written_at so a later attempt does not repeat it.
func TestDeferCeilingRefusal_WritesCardNoteOnFirstRefusal(t *testing.T) {
svc, repo := newServiceWithRealRepo(t)
ctx := context.Background()
seedTaskAndSession(t, repo, "surface-task", "surface-session", models.TaskSessionStateRunning)
messages := &mockMessageCreator{}
svc.messageCreator = messages
require.NoError(t, svc.deferCeilingRefusal(
ctx, "surface-task", "surface-session", models.CeilingLaunchResume,
map[string]interface{}{metaKeySessionID: "surface-session"}, ceilingReasonRefused, 3, true, 3,
))
require.Len(t, messages.sessionMessages, 1)
note := messages.sessionMessages[0]
require.Equal(t, "surface-session", note.sessionID)
require.Equal(t, metaVariantCeiling, note.metadata[metaKeyVariant])
require.Equal(t, ceilingReasonRefused, note.metadata[ceilingFieldReasonCode])
record := deferredLaunchOf(t, svc, "surface-task")
require.NotEmpty(t, record[models.CeilingSurfaceWrittenAtKey], "the success stamp must be recorded")
}
// TestDeferCeilingRefusal_SeamOneNeverWritesACardNote pins AC-49's exclusion
// of seam 1: a "start" kind refusal carries no session id, so it must never
// reach the carrier at all.
func TestDeferCeilingRefusal_SeamOneNeverWritesACardNote(t *testing.T) {
svc, repo := newServiceWithRealRepo(t)
ctx := context.Background()
require.NoError(t, repo.CreateTask(ctx, &models.Task{ID: "surface-seam1-task", Title: "T"}))
messages := &mockMessageCreator{}
svc.messageCreator = messages
require.NoError(t, svc.deferCeilingRefusal(
ctx, "surface-seam1-task", "", models.CeilingLaunchStart,
map[string]interface{}{"prompt": "p"}, ceilingReasonRefused, 3, true, 3,
))
require.Empty(t, messages.sessionMessages)
record := deferredLaunchOf(t, svc, "surface-seam1-task")
require.Nil(t, record[models.CeilingSurfaceWrittenAtKey])
}
// TestDeferCeilingRefusal_SameReasonDoesNotRewriteTheNote pins AC-49e:
// suppression is keyed on a note having been successfully written, so a
// repeat refusal for the same unchanged reason must not write a second one.
func TestDeferCeilingRefusal_SameReasonDoesNotRewriteTheNote(t *testing.T) {
svc, repo := newServiceWithRealRepo(t)
ctx := context.Background()
seedTaskAndSession(t, repo, "surface-dup-task", "surface-dup-session", models.TaskSessionStateRunning)
messages := &mockMessageCreator{}
svc.messageCreator = messages
payload := map[string]interface{}{metaKeySessionID: "surface-dup-session"}
require.NoError(t, svc.deferCeilingRefusal(ctx, "surface-dup-task", "surface-dup-session", models.CeilingLaunchResume, payload, ceilingReasonRefused, 3, true, 3))
require.NoError(t, svc.deferCeilingRefusal(ctx, "surface-dup-task", "surface-dup-session", models.CeilingLaunchResume, payload, ceilingReasonRefused, 3, true, 3))
require.Len(t, messages.sessionMessages, 1, "an unchanged reason must not write a second note")
}
func TestWriteCeilingSurfaceNote_IsIdentityGuardedAndIdempotent(t *testing.T) {
svc, repo := newServiceWithRealRepo(t)
ctx := context.Background()
seedTaskAndSession(t, repo, "surface-identity-task", "surface-identity-session", models.TaskSessionStateRunning)
messages := &mockMessageCreator{}
svc.messageCreator = messages
old := models.CeilingDeferral{
Kind: models.CeilingLaunchResume, Origin: string(launchOriginAutomatic),
ReasonCode: ceilingReasonRefused, QueuedAt: time.Now().UTC().Truncate(time.Second), Ceiling: 3,
Payload: map[string]interface{}{metaKeySessionID: "surface-identity-session"},
}
newer := old
newer.ReasonCode = ceilingReasonUnknownPopulation
require.NoError(t, repo.SetTaskMetadataKey(ctx, "surface-identity-task", models.MetaKeyDeferredLaunch, models.CeilingRecordKeys(newer)))
require.ErrorIs(t, svc.writeCeilingSurfaceNote(ctx, "surface-identity-task", "surface-identity-session", old), errCeilingSurfaceSuperseded)
require.Empty(t, messages.sessionMessages)
require.NoError(t, repo.SetTaskMetadataKey(ctx, "surface-identity-task", models.MetaKeyDeferredLaunch, models.CeilingRecordKeys(old)))
require.NoError(t, svc.writeCeilingSurfaceNote(ctx, "surface-identity-task", "surface-identity-session", old))
require.NoError(t, svc.writeCeilingSurfaceNote(ctx, "surface-identity-task", "surface-identity-session", old))
require.Len(t, messages.sessionMessages, 1)
}
// TestDeferCeilingRefusal_ReasonChangeRewritesTheRecordAndTheNote pins AC-49f:
// the same pending launch refused again for a different reason updates the
// stored reason code and gets its own note.
func TestDeferCeilingRefusal_ReasonChangeRewritesTheRecordAndTheNote(t *testing.T) {
svc, repo := newServiceWithRealRepo(t)
ctx := context.Background()
seedTaskAndSession(t, repo, "surface-change-task", "surface-change-session", models.TaskSessionStateRunning)
messages := &mockMessageCreator{}
svc.messageCreator = messages
payload := map[string]interface{}{metaKeySessionID: "surface-change-session"}
require.NoError(t, svc.deferCeilingRefusal(ctx, "surface-change-task", "surface-change-session", models.CeilingLaunchResume, payload, ceilingReasonUnknownPopulation, 0, false, 3))
require.NoError(t, svc.deferCeilingRefusal(ctx, "surface-change-task", "surface-change-session", models.CeilingLaunchResume, payload, ceilingReasonRefused, 3, true, 3))
require.Len(t, messages.sessionMessages, 2, "a reason-code change must write its own note")
require.Equal(t, ceilingReasonUnknownPopulation, messages.sessionMessages[0].metadata[ceilingFieldReasonCode])
require.Equal(t, ceilingReasonRefused, messages.sessionMessages[1].metadata[ceilingFieldReasonCode])
record := deferredLaunchOf(t, svc, "surface-change-task")
require.Equal(t, ceilingReasonRefused, record[models.CeilingReasonCodeKey])
require.NotEmpty(t, record[models.CeilingSurfaceWrittenAtKey])
}
// TestAttemptCeilingSurfaceWrite_RetriesUpToBudgetThenStops pins
// AC-49g/AC-49g1: a carrier write that keeps failing is retried on each
// sweep pass, bounded at five consecutive failures, after which no further
// attempt is made.
func TestAttemptCeilingSurfaceWrite_RetriesUpToBudgetThenStops(t *testing.T) {
svc, repo := newServiceWithRealRepo(t)
ctx := context.Background()
seedTaskAndSession(t, repo, "surface-fail-task", "surface-fail-session", models.TaskSessionStateRunning)
failing := &mockMessageCreator{sessionMessageErr: errors.New("carrier down")}
svc.messageCreator = failing
require.NoError(t, svc.deferCeilingRefusal(
ctx, "surface-fail-task", "surface-fail-session", models.CeilingLaunchResume,
map[string]interface{}{metaKeySessionID: "surface-fail-session"}, ceilingReasonRefused, 3, true, 3,
))
require.Equal(t, 1, failing.sessionMessageAttempts)
// Four more sweep-driven retries reach the five-attempt budget.
for i := 0; i < 4; i++ {
svc.attemptCeilingSurfaceWrite(ctx, "surface-fail-task")
}
require.Equal(t, 5, failing.sessionMessageAttempts)
// The budget is exhausted: a further tick must not attempt again.
svc.attemptCeilingSurfaceWrite(ctx, "surface-fail-task")
require.Equal(t, 5, failing.sessionMessageAttempts, "must stop attempting once the budget is exhausted")
record := deferredLaunchOf(t, svc, "surface-fail-task")
require.Nil(t, record[models.CeilingSurfaceWrittenAtKey])
require.EqualValues(t, 5, record[models.CeilingSurfaceAttemptCountKey])
}
// TestAttemptCeilingSurfaceWrite_SucceedsAfterEarlierFailuresStampsWritten
// covers the recovery path: once the carrier starts working again, the next
// attempt succeeds, stamps ceiling_surface_written_at and clears the failure
// counter.
func TestAttemptCeilingSurfaceWrite_SucceedsAfterEarlierFailuresStampsWritten(t *testing.T) {
svc, repo := newServiceWithRealRepo(t)
ctx := context.Background()
seedTaskAndSession(t, repo, "surface-recover-task", "surface-recover-session", models.TaskSessionStateRunning)
messages := &mockMessageCreator{sessionMessageErr: errors.New("carrier down")}
svc.messageCreator = messages
require.NoError(t, svc.deferCeilingRefusal(
ctx, "surface-recover-task", "surface-recover-session", models.CeilingLaunchResume,
map[string]interface{}{metaKeySessionID: "surface-recover-session"}, ceilingReasonRefused, 3, true, 3,
))
record := deferredLaunchOf(t, svc, "surface-recover-task")
require.EqualValues(t, 1, record[models.CeilingSurfaceAttemptCountKey])
messages.sessionMessageErr = nil
svc.attemptCeilingSurfaceWrite(ctx, "surface-recover-task")
require.Len(t, messages.sessionMessages, 1)
record = deferredLaunchOf(t, svc, "surface-recover-task")
require.NotEmpty(t, record[models.CeilingSurfaceWrittenAtKey])
require.Nil(t, record[models.CeilingSurfaceAttemptCountKey])
}