package runners import ( "context" "log/slog" "time" "github.com/google/uuid" "github.com/yan1h/agent-coding-workflow/internal/infra/notify" ) // ReaperSession is the minimal session projection the reaper needs. type ReaperSession struct { ID uuid.UUID UserID uuid.UUID StartedAt time.Time LastActivityAt time.Time MaxWallClockSeconds *int32 } // AcpSessionReaperRepo is the narrow repo surface this runner needs. // internal/acp.Repository satisfies it via an adapter in app.go. type AcpSessionReaperRepo interface { // ListSessionsForReaper returns active sessions exceeding their wall-clock // cap (per-session or the global wallClockStartedBefore cutoff) or idle since // idleSince. A zero wallClockStartedBefore disables the global wall-clock cut. ListSessionsForReaper(ctx context.Context, wallClockStartedBefore, idleSince time.Time) ([]ReaperSession, error) // MarkSessionTerminatedReason records why a session was reaped (no-op if a // reason is already set, protecting against clobbering a budget-gate reason). MarkSessionTerminatedReason(ctx context.Context, id uuid.UUID, reason string) error } // SessionKiller terminates a session subprocess. internal/acp.Supervisor.Kill // satisfies it directly. type SessionKiller interface { Kill(ctx context.Context, sid uuid.UUID, grace time.Duration) } // AcpSessionReaperConfig controls the reaper's cutoffs. type AcpSessionReaperConfig struct { // WallClockTimeout is the global maximum session age (0 = rely only on the // per-session budget_max_wall_clock_seconds). WallClockTimeout time.Duration // IdleTimeout is the max time with no new activity before reaping. IdleTimeout time.Duration // KillGrace is passed to SessionKiller.Kill. KillGrace time.Duration } // AcpSessionReaper terminates ACP sessions that exceed wall-clock or are idle. type AcpSessionReaper struct { repo AcpSessionReaperRepo killer SessionKiller disp *notify.Dispatcher cfg AcpSessionReaperConfig log *slog.Logger } // NewAcpSessionReaper builds the reaper. func NewAcpSessionReaper(repo AcpSessionReaperRepo, killer SessionKiller, disp *notify.Dispatcher, cfg AcpSessionReaperConfig, log *slog.Logger) *AcpSessionReaper { if log == nil { log = slog.Default() } if cfg.KillGrace <= 0 { cfg.KillGrace = 5 * time.Second } return &AcpSessionReaper{repo: repo, killer: killer, disp: disp, cfg: cfg, log: log} } // Run reaps once. Periodic; idempotent (terminated_reason is only set once). func (r *AcpSessionReaper) Run(ctx context.Context) { now := time.Now() var wallBefore time.Time if r.cfg.WallClockTimeout > 0 { wallBefore = now.Add(-r.cfg.WallClockTimeout) } idleSince := now.Add(-r.cfg.IdleTimeout) if r.cfg.IdleTimeout <= 0 { // No idle timeout configured -> make the idle cutoff unreachable so only // wall-clock rules apply (a zero time would reap everything). idleSince = time.Time{} } sessions, err := r.repo.ListSessionsForReaper(ctx, wallBefore, idleSince) if err != nil { r.log.Error("acp_session_reaper.list", "err", err.Error()) return } for _, s := range sessions { reason := r.classify(now, s) if reason == "" { continue } if merr := r.repo.MarkSessionTerminatedReason(ctx, s.ID, reason); merr != nil { r.log.Error("acp_session_reaper.mark", "session_id", s.ID, "err", merr.Error()) // continue: still attempt the kill so the resource is freed. } // Kill must not be fatal on error (idempotent; session may have already // exited between listing and now). r.killer.Kill(ctx, s.ID, r.cfg.KillGrace) r.log.Info("acp_session_reaper.killed", "session_id", s.ID, "reason", reason) r.dispatch(ctx, s, reason) } } // classify decides which reaper reason applies, preferring wall-clock over idle. func (r *AcpSessionReaper) classify(now time.Time, s ReaperSession) string { // Per-session wall-clock cap. if s.MaxWallClockSeconds != nil { if now.Sub(s.StartedAt) >= time.Duration(*s.MaxWallClockSeconds)*time.Second { return "reaper_wall_clock" } } // Global wall-clock cap. if r.cfg.WallClockTimeout > 0 && now.Sub(s.StartedAt) >= r.cfg.WallClockTimeout { return "reaper_wall_clock" } // Idle cap. if r.cfg.IdleTimeout > 0 { activity := s.LastActivityAt if activity.IsZero() { activity = s.StartedAt } if now.Sub(activity) >= r.cfg.IdleTimeout { return "reaper_idle" } } return "" } func (r *AcpSessionReaper) dispatch(ctx context.Context, s ReaperSession, reason string) { if r.disp == nil { return } _ = r.disp.Dispatch(ctx, notify.Message{ UserID: s.UserID, Topic: "acp.session_reaped", Severity: notify.SeverityWarning, Title: "ACP session terminated by reaper", Body: "An ACP session was terminated: " + reason + ".", Metadata: map[string]any{ "session_id": s.ID.String(), "reason": reason, }, }) }