Files
Elydora-Go-SDK/cmd/elydora/plugins/claudecode_retry_test.go
T

195 lines
6.8 KiB
Go

package plugins
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
type mismatchAPI struct {
mu sync.Mutex
rejections int
delay time.Duration
staleHead bool
staleLock bool
liveLock bool
statePath string
operations []map[string]any
}
const mismatchExpected = "Rxlf4j36C3KvIQ3hWuOkX698BR5iDypUFuB70JjEuvM"
func (api *mismatchAPI) ServeHTTP(response http.ResponseWriter, request *http.Request) {
api.mu.Lock()
defer api.mu.Unlock()
response.Header().Set("Content-Type", "application/json")
if request.Method != http.MethodPost || request.URL.Path != "/v1/operations" {
_ = json.NewEncoder(response).Encode(map[string]any{"agent": map[string]any{"status": "active"}})
return
}
var operation map[string]any
if err := json.NewDecoder(request.Body).Decode(&operation); err != nil {
http.Error(response, err.Error(), http.StatusBadRequest)
return
}
api.operations = append(api.operations, operation)
count := len(api.operations)
if api.delay > 0 {
api.mu.Unlock()
time.Sleep(api.delay)
api.mu.Lock()
}
if api.statePath != "" {
_ = os.WriteFile(api.statePath, []byte(`{"prev_chain_hash":"`+strings.Repeat("B", 43)+`"}`), 0o600)
}
if count > api.rejections {
response.WriteHeader(http.StatusAccepted)
_, _ = fmt.Fprintf(response, `{"receipt":{"seq_no":%d}}`, count)
return
}
expected := mismatchExpected
if api.rejections != 1 {
expected = strings.Repeat("A", 42) + fmt.Sprint(count)
}
response.WriteHeader(http.StatusBadRequest)
_, _ = fmt.Fprintf(
response,
`{"error":{"code":"PREV_HASH_MISMATCH","message":"Expected prev_chain_hash \"%s\", got \"x\"."}}`,
expected,
)
}
func runClaudeAuditAgainst(t *testing.T, api *mismatchAPI) (*claudeFixture, []map[string]any, time.Duration) {
t.Helper()
server := httptest.NewServer(api)
defer server.Close()
fixture := prepareClaudeFixture(t, claudeFixtureOptions{})
fixture.config.BaseURL = server.URL
fixture.config.Token = "ely_test_token"
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Claude hooks: %v", err)
}
settings := readClaudeTestObject(t, fixture.configPath)
audit := claudeTestManagedHandler(t, settings, "PostToolUse", claudeAuditScript, claudeAuditStatusMessage)
if api.staleHead {
api.mu.Lock()
api.statePath = filepath.Join(fixture.agentDir, "chain-state.json")
api.mu.Unlock()
}
if api.staleLock || api.liveLock {
lockPath := filepath.Join(fixture.agentDir, "chain-state.json.lock")
var owner []byte
if api.liveLock {
owner = []byte(fmt.Sprint(os.Getpid()))
}
if err := os.WriteFile(lockPath, owner, 0o600); err != nil {
t.Fatalf("write stale lock: %v", err)
}
stale := time.Now().Add(-10 * time.Second)
if err := os.Chtimes(lockPath, stale, stale); err != nil {
t.Fatalf("age stale lock: %v", err)
}
}
started := time.Now()
exit, stdout, stderr := runClaudeHandler(t, audit, fixture, claudeOfficialPayload("PostToolUse"))
elapsed := time.Since(started)
if exit != 0 || stdout != "" || stderr != "" {
t.Fatalf("audit result = %d, %q, %q", exit, stdout, stderr)
}
api.mu.Lock()
defer api.mu.Unlock()
return fixture, api.operations, elapsed
}
func TestClaudeAuditRetriesRejectedChainHash(t *testing.T) {
fixture, operations, _ := runClaudeAuditAgainst(t, &mismatchAPI{rejections: 1})
if len(operations) != 2 || operations[1]["prev_chain_hash"] != mismatchExpected {
t.Fatalf("operations = %#v", operations)
}
if operations[0]["operation_id"] == operations[1]["operation_id"] || operations[0]["nonce"] == operations[1]["nonce"] {
t.Fatalf("retry reused identifiers: %#v", operations)
}
state, err := os.ReadFile(filepath.Join(fixture.agentDir, "chain-state.json"))
if err != nil {
t.Fatalf("read chain state: %v", err)
}
if !strings.Contains(string(state), fmt.Sprint(operations[1]["chain_hash"])) {
t.Fatalf("chain state %s does not hold %v", state, operations[1]["chain_hash"])
}
log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log"))
if err != nil || !strings.Contains(string(log), "resynced to server: Rxlf") {
t.Fatalf("error log = %q, %v", log, err)
}
}
func TestClaudeAuditStopsAfterFiveRejections(t *testing.T) {
fixture, operations, _ := runClaudeAuditAgainst(t, &mismatchAPI{rejections: 99})
if len(operations) != 5 || operations[4]["prev_chain_hash"] != strings.Repeat("A", 42)+"4" {
t.Fatalf("operations = %#v", operations)
}
log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log"))
if err != nil || !strings.Contains(string(log), "rejected prev_chain_hash 5 times") {
t.Fatalf("error log = %q, %v", log, err)
}
}
func TestClaudeAuditStopsRetryingWhenBudgetSpent(t *testing.T) {
fixture, operations, elapsed := runClaudeAuditAgainst(t, &mismatchAPI{rejections: 99, delay: 2600 * time.Millisecond})
if elapsed > 7500*time.Millisecond {
t.Fatalf("hook ran %s", elapsed)
}
if len(operations) < 2 || len(operations) > 3 {
t.Fatalf("submissions = %d", len(operations))
}
log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log"))
if err != nil || !(strings.Contains(string(log), "aborted") || strings.Contains(string(log), "retry budget exhausted")) {
t.Fatalf("error log = %q, %v", log, err)
}
}
func TestClaudeAuditDoesNotRegressAConcurrentlyAdvancedChainHead(t *testing.T) {
fixture, operations, _ := runClaudeAuditAgainst(t, &mismatchAPI{staleHead: true})
if len(operations) != 1 || operations[0]["prev_chain_hash"] != strings.Repeat("A", 43) {
t.Fatalf("operations = %#v", operations)
}
state, err := os.ReadFile(filepath.Join(fixture.agentDir, "chain-state.json"))
if err != nil || !strings.Contains(string(state), strings.Repeat("B", 43)) {
t.Fatalf("chain state = %s, %v", state, err)
}
}
func TestClaudeAuditClearsAStaleChainStateLock(t *testing.T) {
fixture, operations, _ := runClaudeAuditAgainst(t, &mismatchAPI{staleLock: true})
if len(operations) != 1 {
t.Fatalf("operations = %#v", operations)
}
if _, err := os.Stat(filepath.Join(fixture.agentDir, "chain-state.json.lock")); !os.IsNotExist(err) {
t.Fatalf("stale lock still present: %v", err)
}
state, err := os.ReadFile(filepath.Join(fixture.agentDir, "chain-state.json"))
if err != nil || !strings.Contains(string(state), fmt.Sprint(operations[0]["chain_hash"])) {
t.Fatalf("chain state = %s, %v", state, err)
}
}
func TestClaudeAuditDoesNotReclaimALiveOwnersLock(t *testing.T) {
fixture, operations, _ := runClaudeAuditAgainst(t, &mismatchAPI{liveLock: true})
if len(operations) != 1 {
t.Fatalf("operations = %#v", operations)
}
if _, err := os.Stat(filepath.Join(fixture.agentDir, "chain-state.json.lock")); err != nil {
t.Fatalf("live lock was removed: %v", err)
}
log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log"))
if err != nil || !strings.Contains(string(log), "lock timed out") {
t.Fatalf("error log = %q, %v", log, err)
}
}