You've already forked agentic-coding-workflow
149 lines
4.8 KiB
Go
149 lines
4.8 KiB
Go
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,
|
|
},
|
|
})
|
|
}
|