package runners_test import ( "context" "errors" "sync" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/yan1h/agent-coding-workflow/internal/jobs/runners" ) type fakeReaperRepo struct { sessions []runners.ReaperSession listErr error mu sync.Mutex marked map[uuid.UUID]string markErr error } func (f *fakeReaperRepo) ListSessionsForReaper(_ context.Context, _, _ time.Time) ([]runners.ReaperSession, error) { if f.listErr != nil { return nil, f.listErr } return f.sessions, nil } func (f *fakeReaperRepo) MarkSessionTerminatedReason(_ context.Context, id uuid.UUID, reason string) error { f.mu.Lock() defer f.mu.Unlock() if f.marked == nil { f.marked = map[uuid.UUID]string{} } f.marked[id] = reason return f.markErr } type fakeKiller struct { mu sync.Mutex killed []uuid.UUID } func (k *fakeKiller) Kill(_ context.Context, sid uuid.UUID, _ time.Duration) { k.mu.Lock() defer k.mu.Unlock() k.killed = append(k.killed, sid) } func TestAcpSessionReaper_KillsWallClockAndIdle(t *testing.T) { now := time.Now() wallSession := runners.ReaperSession{ ID: uuid.New(), UserID: uuid.New(), StartedAt: now.Add(-5 * time.Hour), LastActivityAt: now.Add(-1 * time.Minute), // active, but wall-clock exceeded } idleSession := runners.ReaperSession{ ID: uuid.New(), UserID: uuid.New(), StartedAt: now.Add(-10 * time.Minute), LastActivityAt: now.Add(-1 * time.Hour), // idle } repo := &fakeReaperRepo{sessions: []runners.ReaperSession{wallSession, idleSession}} killer := &fakeKiller{} r := runners.NewAcpSessionReaper(repo, killer, nil, runners.AcpSessionReaperConfig{ WallClockTimeout: 4 * time.Hour, IdleTimeout: 30 * time.Minute, }, discardLogger()) r.Run(context.Background()) assert.ElementsMatch(t, []uuid.UUID{wallSession.ID, idleSession.ID}, killer.killed) assert.Equal(t, "reaper_wall_clock", repo.marked[wallSession.ID]) assert.Equal(t, "reaper_idle", repo.marked[idleSession.ID]) } func TestAcpSessionReaper_PerSessionWallClock(t *testing.T) { now := time.Now() cap30s := int32(30) s := runners.ReaperSession{ ID: uuid.New(), UserID: uuid.New(), StartedAt: now.Add(-1 * time.Minute), LastActivityAt: now, MaxWallClockSeconds: &cap30s, // exceeded per-session cap, no global cap set } repo := &fakeReaperRepo{sessions: []runners.ReaperSession{s}} killer := &fakeKiller{} r := runners.NewAcpSessionReaper(repo, killer, nil, runners.AcpSessionReaperConfig{ IdleTimeout: time.Hour, // generous idle so only wall-clock triggers }, discardLogger()) r.Run(context.Background()) require.Len(t, killer.killed, 1) assert.Equal(t, s.ID, killer.killed[0]) assert.Equal(t, "reaper_wall_clock", repo.marked[s.ID]) } func TestAcpSessionReaper_SkipsWithinThresholds(t *testing.T) { now := time.Now() s := runners.ReaperSession{ ID: uuid.New(), UserID: uuid.New(), StartedAt: now.Add(-5 * time.Minute), LastActivityAt: now.Add(-1 * time.Minute), } repo := &fakeReaperRepo{sessions: []runners.ReaperSession{s}} killer := &fakeKiller{} r := runners.NewAcpSessionReaper(repo, killer, nil, runners.AcpSessionReaperConfig{ WallClockTimeout: 4 * time.Hour, IdleTimeout: 30 * time.Minute, }, discardLogger()) r.Run(context.Background()) assert.Empty(t, killer.killed) assert.Empty(t, repo.marked) } func TestAcpSessionReaper_MarkErrorStillKills(t *testing.T) { now := time.Now() s := runners.ReaperSession{ ID: uuid.New(), UserID: uuid.New(), StartedAt: now.Add(-5 * time.Hour), LastActivityAt: now, } repo := &fakeReaperRepo{sessions: []runners.ReaperSession{s}, markErr: errors.New("db down")} killer := &fakeKiller{} r := runners.NewAcpSessionReaper(repo, killer, nil, runners.AcpSessionReaperConfig{ WallClockTimeout: 4 * time.Hour, IdleTimeout: 30 * time.Minute, }, discardLogger()) r.Run(context.Background()) require.Len(t, killer.killed, 1) } func TestAcpSessionReaper_ListErrorIsNotFatal(t *testing.T) { repo := &fakeReaperRepo{listErr: errors.New("boom")} killer := &fakeKiller{} r := runners.NewAcpSessionReaper(repo, killer, nil, runners.AcpSessionReaperConfig{ IdleTimeout: 30 * time.Minute, }, discardLogger()) r.Run(context.Background()) assert.Empty(t, killer.killed) }