reaper

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 19, 2026 License: MIT Imports: 8 Imported by: 0

Documentation

Overview

Package reaper finds and kills task side processes (dev servers, watchers, build daemons) that outlive the tmux window they were started in.

Why this exists: ty tears sessions down at the tmux *window* level and relies on SIGHUP propagating from the pane to whatever was running in it. That works for the agent, which stays in the pane's foreground process group, and fails for anything that left that group — a dev server that was backgrounded, disowned, or setsid'd. Once its parent shell dies such a process is reparented to launchd/init and never sees the signal, so it survives every window teardown, forever. Four of them (ages 1d8h..4d20h, ~4GB of compressed swap ballast) helped exhaust a 24GB machine on 2026-08-19.

The reaper deliberately does NOT reason about process ancestry, process groups, or environment variables to decide what belongs to a task. Those have each failed this code once already (see cleanupOrphanedSessions' own doc comment for the previous incarnation). It matches on the worktree path, which is right there on the command line of every process a task started and cannot be lost by reparenting.

Index

Constants

View Source
const (
	// DefaultDoneGrace is how long after a task finishes we still leave its
	// side processes alone. Matches the window sweep's existing 2h grace: a
	// done task is finished work, nobody is coming back to its dev server.
	DefaultDoneGrace = 2 * time.Hour

	// DefaultBlockedIdle is how long a task must show NO activity before its
	// side processes are considered abandoned.
	//
	// In ty, "blocked" overwhelmingly means "waiting for a human to come look
	// at it", not "dead". Reaping on the done-task grace (2h) would routinely
	// kill the dev server behind a PR that is simply awaiting review over
	// lunch. A full day of zero activity — no status change, no log line, no
	// one opening it in the UI — is a much stronger signal that nobody is
	// mid-flight on it, and the cost of being wrong is small and recoverable:
	// only side processes are reaped at this threshold (never the agent), the
	// worktree and the agent's resumable session are untouched, and restarting
	// a dev server is one command.
	DefaultBlockedIdle = 24 * time.Hour

	// DefaultOrphanMinAge is the minimum age for the no-worktree heuristic
	// (PPID 1 + no controlling terminal + a known dev-server binary). Nothing
	// ties these back to a task, so the only evidence we have is that they are
	// detached, headless, and old. Kept deliberately long.
	DefaultOrphanMinAge = 24 * time.Hour

	// DefaultTermGrace is how long a process gets to exit after SIGTERM before
	// it is SIGKILLed.
	DefaultTermGrace = 5 * time.Second

	// Never is the threshold value that disables a staleness rule: no elapsed
	// time can ever reach it. Callers set it when a setting is "0"/"disabled".
	Never = time.Duration(1<<62 - 1)
)

Defaults for the staleness thresholds. All are overridable via settings.

Variables

This section is empty.

Functions

func IsAgentProcess

func IsAgentProcess(command string) bool

IsAgentProcess reports whether a command line looks like one of the coding agents ty launches, rather than a side process it spawned.

func IsDevServer

func IsDevServer(command string) bool

IsDevServer reports whether a command line is one of the known long-lived JS dev servers used by the no-worktree heuristic.

func LivePanePIDs

func LivePanePIDs() map[int]bool

LivePanePIDs returns the PID of every process tmux currently owns a pane for, across all sessions. Anything descended from one of these is still in a pane and therefore not an orphan.

func ParseElapsed

func ParseElapsed(s string) (time.Duration, bool)

ParseElapsed parses ps's etime column: [[dd-]hh:]mm:ss.

func ProtectedPIDs

func ProtectedPIDs(procs []Process) map[int]bool

ProtectedPIDs returns the sweeping process and every ancestor of it. `ty sessions cleanup` is frequently run by an agent from inside its own task worktree, where its whole process chain matches that worktree's path; without this the sweep would be able to kill the thing running it.

func SystemSignaller

func SystemSignaller(pid int, sig syscall.Signal) error

SystemSignaller delivers signals for real.

func TaskIDFor

func TaskIDFor(command string) int

TaskIDFor returns the task ID referenced by a command line, or 0.

func WorktreeDirFor

func WorktreeDirFor(command string) string

WorktreeDirFor returns the task worktree directory name referenced by a command line ("5119-creator-referral-v2"), or "".

Types

type Decision

type Decision struct {
	Process Process
	TaskID  int // 0 when the process could not be tied to a task
	IsAgent bool
	Reap    bool
	Rule    Rule
	Reason  string
}

Decision is the verdict for one process the sweep considered. Every process the reaper looked at produces one, reaped or not, so a dry run explains its reasoning for the keeps as well as the kills.

func Plan

func Plan(procs []Process, tasks map[int]TaskState, livePanePIDs map[int]bool, p Policy) []Decision

Plan decides what to do with every process it can tie to a task worktree, plus detached dev servers that no worktree explains. It is pure: callers supply the process table, the live tmux pane PIDs, and the task states, and get back the decisions without anything being signalled.

func (Decision) String

func (d Decision) String() string

String renders a decision as a single log line.

type KillResult

type KillResult struct {
	Decision Decision
	Signal   string // "TERM", "KILL", or "" when nothing was sent
	Err      error
}

KillResult records what happened to one reaped process.

func Reap

func Reap(decisions []Decision, grace time.Duration, sig Signaller) []KillResult

Reap SIGTERMs everything marked for reaping, waits out the grace period, and SIGKILLs whatever is still alive. Decisions that are not marked Reap are ignored, so the caller can hand it the full plan.

type Policy

type Policy struct {
	Now                  time.Time
	DoneGrace            time.Duration
	BlockedIdle          time.Duration
	OrphanMinAge         time.Duration
	ReapOrphanDevServers bool
	// Protected PIDs are never touched: the sweeping process itself and every
	// one of its ancestors. Without this, `ty sessions cleanup` run by an agent
	// from inside its own task worktree would reap the agent running it.
	Protected map[int]bool
	// OnlyTasks, when non-empty, restricts the sweep to these task IDs. Used by
	// `ty sessions suspend`, which should only clean up after what it suspended.
	OnlyTasks map[int]bool
	// Explicit marks a teardown the user asked for by name rather than one
	// inferred from staleness. Status-based caution (the active-task guard, the
	// idle thresholds, sparing the agent) exists to avoid guessing wrong about
	// abandoned work; when someone runs `ty sessions suspend 42` there is no
	// guessing left to do. Only meaningful together with OnlyTasks.
	Explicit bool
}

Policy carries the thresholds and the invariants a sweep must respect.

type Process

type Process struct {
	PID     int
	PPID    int
	TTY     string        // "?" / "??" when the process has no controlling terminal
	Age     time.Duration // elapsed time since the process started
	Command string        // full command line
}

Process is one row of the system process table.

func ParsePS

func ParsePS(out string) []Process

ParsePS parses `ps -Ao pid=,ppid=,tty=,etime=,command=` output. Rows that don't parse are skipped rather than failing the sweep — a malformed line must never be able to stop the reaper, and must never be interpreted as a kill.

func ScanProcesses

func ScanProcesses() ([]Process, error)

ScanProcesses returns the current process table.

func (Process) HasTTY

func (p Process) HasTTY() bool

HasTTY reports whether the process has a controlling terminal. ps renders the absence as "?" on Linux and "??" on macOS.

type Rule

type Rule string

Rule identifies which decision produced a Decision, for logging and tests.

const (
	RuleProtected     Rule = "protected"
	RuleLivePane      Rule = "live-pane"
	RuleTaskDone      Rule = "task-done"
	RuleDoneGrace     Rule = "within-done-grace"
	RuleTaskActive    Rule = "task-active"
	RuleStaleIdle     Rule = "stale-idle"
	RuleIdleGrace     Rule = "within-idle-grace"
	RuleAgentSpared   Rule = "agent-spared"
	RuleOrphanDetach  Rule = "detached-dev-server"
	RuleOrphanYoung   Rule = "detached-but-young"
	RuleOrphanAttach  Rule = "not-detached"
	RuleOrphanDisable Rule = "detached-sweep-disabled"
	RuleExplicit      Rule = "explicit-teardown"
	RuleUnknownTask   Rule = "not-our-task"
)

type Signaller

type Signaller func(pid int, sig syscall.Signal) error

Signaller sends a signal to a PID. Swappable so tests can observe the kill sequence without touching real processes.

type TaskState

type TaskState struct {
	Exists bool
	Status string
	// LastActivity is the most recent sign of life for the task: status
	// changes, log lines, the UI opening it. Staleness is measured from here
	// and NOT from when the task entered its current status, so a task that is
	// blocked but actively being poked at is never considered abandoned.
	LastActivity time.Time
	// WorktreeDir is the basename of the task's recorded worktree
	// ("5119-creator-referral-v2"), or "" if none is recorded. A process whose
	// worktree directory differs belongs to some other task that happens to
	// share the ID — typically one placed here from another machine.
	WorktreeDir string
}

TaskState is the slice of a task the reaper needs to judge its processes.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL