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
- func IsAgentProcess(command string) bool
- func IsDevServer(command string) bool
- func LivePanePIDs() map[int]bool
- func ParseElapsed(s string) (time.Duration, bool)
- func ProtectedPIDs(procs []Process) map[int]bool
- func SystemSignaller(pid int, sig syscall.Signal) error
- func TaskIDFor(command string) int
- func WorktreeDirFor(command string) string
- type Decision
- type KillResult
- type Policy
- type Process
- type Rule
- type Signaller
- type TaskState
Constants ¶
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 ¶
IsAgentProcess reports whether a command line looks like one of the coding agents ty launches, rather than a side process it spawned.
func IsDevServer ¶
IsDevServer reports whether a command line is one of the known long-lived JS dev servers used by the no-worktree heuristic.
func LivePanePIDs ¶
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 ¶
ParseElapsed parses ps's etime column: [[dd-]hh:]mm:ss.
func ProtectedPIDs ¶
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 ¶
SystemSignaller delivers signals for real.
func WorktreeDirFor ¶
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.
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.
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 ¶
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 ¶
ScanProcesses returns the current process table.
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 ¶
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.