220 lines
7.6 KiB
Go
220 lines
7.6 KiB
Go
package models
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestLoadLastAgentErrorUsesExplicitStampAndNormalizesRecoveryFields(t *testing.T) {
|
|
metadata := map[string]interface{}{
|
|
SessionMetaKeyLastAgentError: map[string]interface{}{
|
|
"message": "base branch is missing",
|
|
"occurred_at": "2026-08-19T10:00:00Z",
|
|
"stamp": "pr-state-1",
|
|
"task_repository_id": "task-repo-1",
|
|
"recovery_actions": []string{"retry_default", "retry_default", "unknown", "pick_base_branch", "mark_review_done"},
|
|
},
|
|
}
|
|
|
|
lastError, ok := LoadLastAgentError(metadata)
|
|
require.True(t, ok)
|
|
require.Equal(t, "pr-state-1", lastError.Stamp())
|
|
require.Equal(t, "task-repo-1", lastError.TaskRepositoryID)
|
|
require.Equal(t, []string{
|
|
RecoveryActionRetryDefault,
|
|
RecoveryActionPickBaseBranch,
|
|
RecoveryActionMarkReviewDone,
|
|
}, lastError.RecoveryActions)
|
|
}
|
|
|
|
func TestNormalizeRecoveryActionsForCategoryFiltersActionsByLaunchCause(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
category string
|
|
input []string
|
|
want []string
|
|
}{
|
|
{
|
|
name: "generic launch failure",
|
|
category: LaunchErrorCategoryGenericLaunchFailure,
|
|
input: []string{RecoveryActionRetryDefault, RecoveryActionPickBaseBranch, RecoveryActionMarkReviewDone},
|
|
want: []string{RecoveryActionRetryLaunch},
|
|
},
|
|
{
|
|
name: "workspace checkout failure",
|
|
category: LaunchErrorCategoryWorkspaceCheckoutFailed,
|
|
input: []string{RecoveryActionRetryDefault, RecoveryActionRetryLaunch},
|
|
want: []string{RecoveryActionRetryLaunch},
|
|
},
|
|
{
|
|
name: "missing base branch",
|
|
category: LaunchErrorCategoryBaseBranchMissing,
|
|
input: []string{RecoveryActionRetryLaunch, RecoveryActionRetryDefault, RecoveryActionPickBaseBranch},
|
|
want: []string{RecoveryActionRetryDefault, RecoveryActionPickBaseBranch},
|
|
},
|
|
{
|
|
name: "closed pull request",
|
|
category: LaunchErrorCategoryPRAlreadyClosed,
|
|
input: []string{RecoveryActionRetryLaunch, RecoveryActionMarkReviewDone},
|
|
want: []string{RecoveryActionMarkReviewDone},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
require.Equal(t, test.want, NormalizeRecoveryActionsForCategory(test.category, test.input))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTaskLaunchErrorStoreIsNoOpForSameStamp(t *testing.T) {
|
|
firstTime := time.Date(2026, 8, 19, 10, 0, 0, 0, time.UTC)
|
|
secondTime := firstTime.Add(time.Minute)
|
|
metadata := map[string]interface{}{}
|
|
|
|
first := TaskLaunchError{
|
|
Message: "base branch is missing",
|
|
OccurredAt: firstTime,
|
|
StampValue: "pr-state-1",
|
|
TaskRepositoryID: "task-repo-1",
|
|
RecoveryActions: []string{RecoveryActionRetryDefault},
|
|
}
|
|
require.True(t, SetTaskLaunchError(metadata, first))
|
|
|
|
second := first
|
|
second.OccurredAt = secondTime
|
|
second.Message = "a newer message for the same PR state"
|
|
require.False(t, SetTaskLaunchError(metadata, second))
|
|
|
|
stored, ok := LoadTaskLaunchError(metadata)
|
|
require.True(t, ok)
|
|
require.Equal(t, firstTime, stored.OccurredAt)
|
|
require.Equal(t, first.Message, stored.Message)
|
|
require.Equal(t, first.StampValue, stored.Stamp())
|
|
}
|
|
|
|
func TestTaskLaunchErrorClearRequiresMatchingStamp(t *testing.T) {
|
|
metadata := map[string]interface{}{}
|
|
require.True(t, SetTaskLaunchError(metadata, TaskLaunchError{
|
|
Message: "base branch is missing",
|
|
OccurredAt: time.Now().UTC(),
|
|
StampValue: "pr-state-1",
|
|
}))
|
|
|
|
require.False(t, ClearTaskLaunchError(metadata, "pr-state-old"))
|
|
require.True(t, ClearTaskLaunchError(metadata, "pr-state-1"))
|
|
_, ok := LoadTaskLaunchError(metadata)
|
|
require.False(t, ok)
|
|
}
|
|
|
|
func TestStableLaunchErrorStampIsDeterministicAndBounded(t *testing.T) {
|
|
first := StableLaunchErrorStamp("repo-1", "123", "open")
|
|
second := StableLaunchErrorStamp("repo-1", "123", "open")
|
|
require.Equal(t, first, second)
|
|
require.Len(t, first, 32)
|
|
require.NotEqual(t, first, StableLaunchErrorStamp("repo-1", "123", "closed"))
|
|
}
|
|
|
|
func TestLaunchErrorNormalizationBoundsPersistedMessages(t *testing.T) {
|
|
longMessage := strings.Repeat("x", maxLaunchErrorMessageBytes+100)
|
|
|
|
taskMetadata := map[string]interface{}{
|
|
MetaKeyLastLaunchError: TaskLaunchError{Message: longMessage},
|
|
}
|
|
taskError, ok := LoadTaskLaunchError(taskMetadata)
|
|
require.True(t, ok)
|
|
require.Len(t, taskError.Message, maxLaunchErrorMessageBytes)
|
|
|
|
sessionMetadata := map[string]interface{}{
|
|
SessionMetaKeyLastAgentError: LastAgentError{Message: longMessage},
|
|
}
|
|
sessionError, ok := LoadLastAgentError(sessionMetadata)
|
|
require.True(t, ok)
|
|
require.Len(t, sessionError.Message, maxLaunchErrorMessageBytes)
|
|
}
|
|
|
|
func TestLaunchErrorMatchesStampDoesNotFallBackWhenExplicitStampExists(t *testing.T) {
|
|
occurredAt := time.Date(2026, 8, 19, 10, 0, 0, 0, time.UTC)
|
|
legacyStamp := occurredAt.Format(time.RFC3339Nano) + ":boom"
|
|
|
|
taskError := TaskLaunchError{Message: "boom", OccurredAt: occurredAt, StampValue: "explicit-task"}
|
|
require.True(t, taskError.MatchesStamp("explicit-task"))
|
|
require.False(t, taskError.MatchesStamp(legacyStamp))
|
|
|
|
sessionError := LastAgentError{Message: "boom", OccurredAt: occurredAt, StampValue: "explicit-session"}
|
|
require.True(t, sessionError.MatchesStamp("explicit-session"))
|
|
require.False(t, sessionError.MatchesStamp(legacyStamp))
|
|
}
|
|
|
|
func TestLastAgentErrorNormalizesBootstrapCorrelationAndCauses(t *testing.T) {
|
|
metadata := map[string]interface{}{
|
|
SessionMetaKeyLastAgentError: map[string]interface{}{
|
|
"message": "The agent could not start.",
|
|
"occurred_at": "2026-09-11T10:00:00Z",
|
|
"phase": "bootstrap",
|
|
"execution_id": strings.Repeat("e", maxLaunchErrorIDBytes+20),
|
|
"attempt_id": strings.Repeat("a", maxLaunchErrorIDBytes+20),
|
|
"details": strings.Repeat("d", maxLaunchErrorDetailsBytes),
|
|
"causes": []map[string]interface{}{
|
|
{
|
|
"operation": "resume",
|
|
"code": AgentErrorCauseCodePermissionDenied,
|
|
"detail": strings.Repeat("r", maxAgentErrorCauseDetailBytes+20),
|
|
},
|
|
{
|
|
"operation": "restore_workspace",
|
|
"code": AgentErrorCauseCodeTransportUnavailable,
|
|
"detail": "workspace access failed",
|
|
},
|
|
{
|
|
"operation": "resume",
|
|
"code": "raw-provider-error",
|
|
"detail": "must be ignored",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
lastError, ok := LoadLastAgentError(metadata)
|
|
require.True(t, ok)
|
|
require.Equal(t, LaunchErrorPhaseBootstrap, lastError.Phase)
|
|
require.Len(t, lastError.ExecutionID, maxLaunchErrorIDBytes)
|
|
require.Len(t, lastError.AttemptID, maxLaunchErrorIDBytes)
|
|
require.Len(t, lastError.Causes, 2)
|
|
require.Len(t, lastError.Causes[0].Detail, maxAgentErrorCauseDetailBytes)
|
|
require.Equal(t, AgentErrorCauseCodePermissionDenied, lastError.Causes[0].Code)
|
|
require.Equal(t, AgentErrorCauseCodeTransportUnavailable, lastError.Causes[1].Code)
|
|
require.LessOrEqual(t, len(lastError.Details)+sumCauseDetailBytes(lastError.Causes), maxLaunchErrorDetailsBytes)
|
|
}
|
|
|
|
func TestLoadLastAgentErrorIgnoresMalformedOptionalBootstrapFields(t *testing.T) {
|
|
metadata := map[string]interface{}{
|
|
SessionMetaKeyLastAgentError: map[string]interface{}{
|
|
"message": "The agent could not start.",
|
|
"occurred_at": "2026-09-11T10:00:00Z",
|
|
"phase": map[string]interface{}{"unexpected": true},
|
|
"execution_id": []string{"unexpected"},
|
|
"attempt_id": 42,
|
|
"causes": "not-a-cause-list",
|
|
},
|
|
}
|
|
|
|
lastError, ok := LoadLastAgentError(metadata)
|
|
require.True(t, ok)
|
|
require.Equal(t, "", lastError.Phase)
|
|
require.Equal(t, "", lastError.ExecutionID)
|
|
require.Equal(t, "", lastError.AttemptID)
|
|
require.Empty(t, lastError.Causes)
|
|
}
|
|
|
|
func sumCauseDetailBytes(causes []AgentErrorCause) int {
|
|
total := 0
|
|
for _, cause := range causes {
|
|
total += len(cause.Detail)
|
|
}
|
|
return total
|
|
}
|