PR #3619
Sections
Review

fix(tasks): reap orphaned processes left running after workspace cleanup

main ← feature/archive-must-kill-ev-38w 30 files +2847 −87 PR #3619 ↗

Task cleanup now reaps host processes that still hold a removed workspace directory, so an archived task cannot leave shells that consume host CPU.

Why this change

Terminal cleanup stops only the executions it recorded. On 2026-09-08 an archived task left twelve shells at 1114 percent CPU with cwd still inside the deleted worktree. The job reported succeeded and nothing reported the leak.

What it does

Architecture, end to end

The cleanup job runs the reap phase last, after workspace removal and remote reclamation. One snapshot feeds attribution, ownership, and signalling.

flowchart LR
  Job[task_resource_cleanup_jobs worker] --> Gather[gatherOrphanReapRootCandidates]
  Gather --> Remove[performTaskCleanup removes worktrees]
  Remove --> Confirm[confirmOrphanReapRootsRemoved]
  Confirm --> Roots[(OrphanReapRoots in snapshot)]
  Roots --> Snapshot[takeOrphanReapHostSnapshot]
  Snapshot --> Attr[attributeOrphanReapCandidates]
  Attr --> Own[applyOrphanReapOwnership]
  Own --> Signal[signalOrphanReapCandidates SIGTERM -> SIGKILL]
  Signal --> Persist[persistOrphanReapProgressBestEffort]

Key code changes

Drag to pan. Use the + and − buttons to zoom. Click a node to open the full code. The arrows show how the parts interact.

drag to pan · +/− to zoom · click a node for details

func (s *Service) executeTaskResourceCleanupJob(ctx context.Context, job *models.TaskResourceCleanupJob, snapshot *taskResourceCleanupSnapshot) error
Click for details →

The job gathers reap roots before removal, confirms them after removal, and runs the reap phase last when stops are clean.

Snapshot fields
type taskResourceCleanupSnapshot struct {
	Sessions               []*models.TaskSession     `json:"sessions,omitempty"`
	Worktrees              []*worktree.Worktree      `json:"worktrees,omitempty"`
	SSHTaskDirs []sshReclaimTarget `json:"ssh_task_dirs,omitempty"`
	OrphanReapRoots []string `json:"orphan_reap_roots,omitempty"`
	OrphanReapRecords []orphanReapCandidateRecord `json:"orphan_reap_records,omitempty"`
	OrphanReapSkips []orphanReapSkipRecord `json:"orphan_reap_skips,omitempty"`
}
Gather, confirm, and run phase
reapRootCandidates := s.gatherOrphanReapRootCandidates(
	snapshot, cleanupSessionIDs(snapshot.Sessions, targets),
)
errs := s.performTaskCleanup(ctx, job.TaskID, snapshot.Sessions, snapshot.Worktrees, targets,
	taskEnvironmentCleanup{
		env: snapshot.TaskEnvironment, deleteRow: snapshot.DeleteEnvironmentRow,
		preserveBranches:       job.IsArchive(),
		discardWorktreeChanges: snapshot.DiscardWorktreeChanges,
	},
	taskCleanupPreserveRows(stopOutcome))
snapshot.OrphanReapRoots = mergeOrphanReapRoots(
	snapshot.OrphanReapRoots, confirmOrphanReapRootsRemoved(reapRootCandidates),
)
if len(failedStops) == 0 {
	errs = append(errs, s.reclaimSSHTaskDirs(ctx, job, snapshot)...)
}
if len(failedStops) == 0 {
	errs = append(errs, s.runOrphanReapPhase(ctx, job, snapshot)...)
}
func (s *Service) runOrphanReapPhase(ctx context.Context, job *models.TaskResourceCleanupJob, snapshot *taskResourceCleanupSnapshot) []error
Click for details →

The phase resolves roots, skips roots that exist again, takes one snapshot, attributes candidates, and applies ownership and signalling.

Gather candidates before removal
func (s *Service) gatherOrphanReapRootCandidates(
	snapshot *taskResourceCleanupSnapshot,
	sessionIDs []string,
) []string {
	seen := make(map[string]struct{})
	var candidates []string
	add := func(path string) {
		path = resolveOrphanReapPathBestEffort(path)
		if path == "" {
			return
		}
		if _, err := os.Lstat(path); err != nil {
			return
		}
		if _, ok := seen[path]; ok {
			return
		}
		seen[path] = struct{}{}
		candidates = append(candidates, path)
	}
	for _, wt := range snapshot.Worktrees {
		if wt == nil || wt.Path == "" {
			continue
		}
		add(wt.Path)
		add(filepath.Dir(wt.Path))
	}
	if s.quickChatDir != "" {
		for _, sessionID := range sessionIDs {
			add(filepath.Join(s.quickChatDir, sessionID))
		}
	}
	return candidates
}
Phase orchestration
func (s *Service) runOrphanReapPhase(ctx context.Context, job *models.TaskResourceCleanupJob, snapshot *taskResourceCleanupSnapshot) []error {
	if len(snapshot.OrphanReapRoots) == 0 {
		return nil
	}
	resolvedRoots := resolveOrphanReapRoots(snapshot.OrphanReapRoots)
	activeRoots := make([]string, 0, len(resolvedRoots))
	for _, root := range resolvedRoots {
		_, err := os.Lstat(root)
		switch {
		case err == nil:
			s.recordOrphanReapRootSkip(snapshot, root, "reap root exists again at reap time")
		case errors.Is(err, os.ErrNotExist):
			activeRoots = append(activeRoots, root)
		default:
			s.recordOrphanReapRootSkipDetectionFailure(snapshot, root, "cannot confirm reap root state: "+err.Error())
		}
	}
	if len(activeRoots) == 0 {
		return nil
	}
	snap, err := s.takeOrphanReapHostSnapshot(ctx)
	if err != nil {
		if errors.Is(err, errOrphanReapUnsupportedPlatform) {
			s.recordOrphanReapPhaseSkip(snapshot, "unsupported platform "+runtime.GOOS)
			return nil
		}
		s.recordOrphanReapPhaseSkipDetectionFailure(snapshot, "host process snapshot unavailable: "+err.Error())
		return nil
	}
	byRoot := attributeOrphanReapCandidates(snap, activeRoots)
	toSignal := s.applyOrphanReapOwnership(ctx, job.TaskID, snap, byRoot, snapshot)
	sort.Slice(toSignal, func(i, j int) bool { return toSignal[i].PID < toSignal[j].PID })
	phaseResult := s.signalOrphanReapCandidatesWithLimit(ctx, job.TaskID, toSignal, snapshot, orphanReapMaxCandidates)
	return phaseResult.errs
}
func (s *Service) applyOrphanReapOwnership(ctx context.Context, taskID string, snap []hostProcess, byRoot map[string][]orphanReapCandidate, snapshot *taskResourceCleanupSnapshot) []orphanReapCandidate
Click for details →

The filter blocks a root or candidate when another task owns the workspace, the PID, or an ancestor, and fails closed on any inconclusive check.

Ownership checks
func (s *Service) applyOrphanReapOwnership(ctx context.Context, taskID string, snap []hostProcess, byRoot map[string][]orphanReapCandidate, snapshot *taskResourceCleanupSnapshot) []orphanReapCandidate {
	ppidByPID := make(map[int]int, len(snap))
	for _, p := range snap {
		ppidByPID[p.PID] = p.PPID
	}
	protected, protectedInconclusive := orphanReapProtectedPIDs(ppidByPID)
	if protectedInconclusive {
		s.skipEveryOrphanReapRoot(snapshot, byRoot, "ownership check inconclusive: could not resolve the backend's own ancestry")
		return nil
	}
	otherExecutors, execErr := s.executors.ListExecutorsRunning(ctx)
	if execErr != nil {
		s.skipEveryOrphanReapRoot(snapshot, byRoot, "ownership check failed: "+execErr.Error())
		return nil
	}
	otherSessions, sessErr := s.sessions.ListLiveWorkspaceSessions(ctx)
	if sessErr != nil {
		s.skipEveryOrphanReapRoot(snapshot, byRoot, "ownership check failed: "+sessErr.Error())
		return nil
	}
	for root, candidates := range byRoot {
		if owner, blocked := orphanReapFindOverlap(otherSessionPaths, root); blocked {
			s.recordOrphanReapRootSkip(snapshot, root, "workspace not exclusively owned: session of task "+owner)
			continue
		}
		if owner, blocked := orphanReapFindContainment(liveWorktreeRoots, root); blocked {
			s.recordOrphanReapRootSkip(snapshot, root, "another task's live recorded execution occupies this workspace: task "+owner)
			continue
		}
		for _, cand := range candidates {
			toSignal = s.applyOrphanReapPerCandidateOwnership(snapshot, taskID, cand, protected, ppidByPID, localPIDOwner, toSignal)
		}
	}
	return toSignal
}
func (s *Service) signalOrphanReapCandidatesWithLimit(ctx context.Context, taskID string, candidates []orphanReapCandidate, snapshot *taskResourceCleanupSnapshot, maxCandidates int) orphanReapSignalPhaseResult
Click for details →

The phase sends SIGTERM to all candidates, waits 2 seconds, sends SIGKILL to survivors, and re-verifies cwd before each signal.

Escalation with re-verify
func (s *Service) signalOrphanReapCandidatesWithLimit(ctx context.Context, taskID string, candidates []orphanReapCandidate, snapshot *taskResourceCleanupSnapshot, maxCandidates int) orphanReapSignalPhaseResult {
	verifier := s.orphanReapVerifier
	if verifier == nil {
		verifier = defaultOrphanReapVerifier()
	}
	signaler := s.orphanReapSignaler
	if signaler == nil {
		signaler = realOrphanReapSignaler{}
	}
	if ctx.Err() != nil {
		return orphanReapSignalPhaseResult{errs: []error{errOrphanReapCancelledMidPhase}}
	}
	termResult := s.sendOrphanReapSigterms(ctx, taskID, candidates, snapshot, verifier, signaler, maxCandidates)
	pending := termResult.pending
	if len(pending) == 0 {
		return result
	}
	graceTimer := time.NewTimer(orphanReapGraceDelay)
	select {
		case <-ctx.Done():
			result.errs = s.recordOrphanReapCancelledMidPhase(snapshot, taskID, pending)
			return result
		case <-graceTimer.C:
		}
	killPending := s.sendOrphanReapSigkills(ctx, taskID, pending, snapshot, verifier, signaler)
	settleTimer := time.NewTimer(orphanReapSettleDelay)
	select {
		case <-ctx.Done():
			result.errs = s.recordOrphanReapCancelledMidPhase(snapshot, taskID, killPending)
			return result
		case <-settleTimer.C:
		}
	result.errs = s.resolveOrphanReapSurvivors(ctx, taskID, killPending, snapshot, verifier, signaler)
	return result
}
Re-verify before signal
func orphanReapReverifyInsideRoot(ctx context.Context, verifier orphanReapVerifier, pid int, root string) bool {
	if !orphanReapRootIsAbsent(root) {
		return false
	}
	verifyCtx, cancel := context.WithTimeout(ctx, orphanReapVerifyTimeout)
	defer cancel()
	cwd, err := verifier.VerifyCwd(verifyCtx, pid)
	if err != nil || cwd == "" {
		return false
	}
	if !orphanReapRootIsAbsent(root) {
		return false
	}
	return orphanReapPathWithinRoot(root, cwd)
}
func (r *Repository) ListLiveWorkspaceSessions(ctx context.Context) ([]*models.TaskSession, error)
Click for details →

The ownership check needs the effective workspace path that prefers the linked environment path over the stale session column.

New repository method
func (r *Repository) ListLiveWorkspaceSessions(ctx context.Context) ([]*models.TaskSession, error) {
	ctx, span := tracing.Tracer("kandev-db").Start(ctx, "db.ListLiveWorkspaceSessions")
	defer span.End()
	rows, err := r.ro.QueryContext(ctx,
		`SELECT `+taskSessionSelectCols+` `+taskSessionFromClause+
			` WHERE ts.state IN ('CREATED', 'STARTING', 'RUNNING', 'IDLE', 'WAITING_FOR_INPUT')`,
	)
	if err != nil {
		return nil, err
	}
	defer func() { _ = rows.Close() }()
	return r.scanTaskSessions(ctx, rows)
}
Interface
type SessionRepository interface {
	ListActiveTaskSessions(ctx context.Context) ([]*models.TaskSession, error)
	ListActiveTaskSessionsByTaskID(ctx context.Context, taskID string) ([]*models.TaskSession, error)
	ListLiveWorkspaceSessions(ctx context.Context) ([]*models.TaskSession, error)
}
func attributeOrphanReapCandidates(snapshot []hostProcess, roots []string) map[string][]orphanReapCandidate
Click for details →

The phase matches processes by working directory on a component boundary and attributes a process to the deepest root.

Attribution and path check
func attributeOrphanReapCandidates(snapshot []hostProcess, roots []string) map[string][]orphanReapCandidate {
	ordered := append([]string(nil), roots...)
	sortByDescendingLength(ordered)
	byRoot := make(map[string][]orphanReapCandidate)
	for _, proc := range snapshot {
		if proc.Cwd == "" {
			continue
		}
		for _, root := range ordered {
			if orphanReapPathWithinRoot(root, proc.Cwd) {
				byRoot[root] = append(byRoot[root], orphanReapCandidate{hostProcess: proc, Root: root})
				break
			}
		}
	}
	return byRoot
}
func orphanReapPathWithinRoot(root, candidate string) bool {
	if root == "" || candidate == "" {
		return false
	}
	if candidate == root {
		return true
	}
	prefix := root
	if !strings.HasSuffix(prefix, string(filepath.Separator)) {
		prefix += string(filepath.Separator)
	}
	return strings.HasPrefix(candidate, prefix)
}
Linux cwd with deleted suffix
func readProcCwd(pid int) (string, error) {
	target, err := os.Readlink("/proc/" + strconv.Itoa(pid) + "/cwd")
	if err != nil {
		return "", err
	}
	return trimProcCwdDeletedSuffix(target), nil
}
func trimProcCwdDeletedSuffix(target string) string {
	return strings.TrimSuffix(target, procDeletedSuffix)
}
Read the changes as a list

Durable snapshot and job integration

apps/backend/internal/task/service/resource_cleanup_jobs.go

The job gathers reap roots before removal, confirms them after removal, and runs the reap phase last when stops are clean.

Snapshot fields
type taskResourceCleanupSnapshot struct {
	Sessions               []*models.TaskSession     `json:"sessions,omitempty"`
	Worktrees              []*worktree.Worktree      `json:"worktrees,omitempty"`
	SSHTaskDirs []sshReclaimTarget `json:"ssh_task_dirs,omitempty"`
	OrphanReapRoots []string `json:"orphan_reap_roots,omitempty"`
	OrphanReapRecords []orphanReapCandidateRecord `json:"orphan_reap_records,omitempty"`
	OrphanReapSkips []orphanReapSkipRecord `json:"orphan_reap_skips,omitempty"`
}
Gather, confirm, and run phase
reapRootCandidates := s.gatherOrphanReapRootCandidates(
	snapshot, cleanupSessionIDs(snapshot.Sessions, targets),
)
errs := s.performTaskCleanup(ctx, job.TaskID, snapshot.Sessions, snapshot.Worktrees, targets,
	taskEnvironmentCleanup{
		env: snapshot.TaskEnvironment, deleteRow: snapshot.DeleteEnvironmentRow,
		preserveBranches:       job.IsArchive(),
		discardWorktreeChanges: snapshot.DiscardWorktreeChanges,
	},
	taskCleanupPreserveRows(stopOutcome))
snapshot.OrphanReapRoots = mergeOrphanReapRoots(
	snapshot.OrphanReapRoots, confirmOrphanReapRootsRemoved(reapRootCandidates),
)
if len(failedStops) == 0 {
	errs = append(errs, s.reclaimSSHTaskDirs(ctx, job, snapshot)...)
}
if len(failedStops) == 0 {
	errs = append(errs, s.runOrphanReapPhase(ctx, job, snapshot)...)
}

Root candidates and phase orchestration

apps/backend/internal/task/service/resource_cleanup_orphan_reap.go

The phase resolves roots, skips roots that exist again, takes one snapshot, attributes candidates, and applies ownership and signalling.

Gather candidates before removal
func (s *Service) gatherOrphanReapRootCandidates(
	snapshot *taskResourceCleanupSnapshot,
	sessionIDs []string,
) []string {
	seen := make(map[string]struct{})
	var candidates []string
	add := func(path string) {
		path = resolveOrphanReapPathBestEffort(path)
		if path == "" {
			return
		}
		if _, err := os.Lstat(path); err != nil {
			return
		}
		if _, ok := seen[path]; ok {
			return
		}
		seen[path] = struct{}{}
		candidates = append(candidates, path)
	}
	for _, wt := range snapshot.Worktrees {
		if wt == nil || wt.Path == "" {
			continue
		}
		add(wt.Path)
		add(filepath.Dir(wt.Path))
	}
	if s.quickChatDir != "" {
		for _, sessionID := range sessionIDs {
			add(filepath.Join(s.quickChatDir, sessionID))
		}
	}
	return candidates
}
Phase orchestration
func (s *Service) runOrphanReapPhase(ctx context.Context, job *models.TaskResourceCleanupJob, snapshot *taskResourceCleanupSnapshot) []error {
	if len(snapshot.OrphanReapRoots) == 0 {
		return nil
	}
	resolvedRoots := resolveOrphanReapRoots(snapshot.OrphanReapRoots)
	activeRoots := make([]string, 0, len(resolvedRoots))
	for _, root := range resolvedRoots {
		_, err := os.Lstat(root)
		switch {
		case err == nil:
			s.recordOrphanReapRootSkip(snapshot, root, "reap root exists again at reap time")
		case errors.Is(err, os.ErrNotExist):
			activeRoots = append(activeRoots, root)
		default:
			s.recordOrphanReapRootSkipDetectionFailure(snapshot, root, "cannot confirm reap root state: "+err.Error())
		}
	}
	if len(activeRoots) == 0 {
		return nil
	}
	snap, err := s.takeOrphanReapHostSnapshot(ctx)
	if err != nil {
		if errors.Is(err, errOrphanReapUnsupportedPlatform) {
			s.recordOrphanReapPhaseSkip(snapshot, "unsupported platform "+runtime.GOOS)
			return nil
		}
		s.recordOrphanReapPhaseSkipDetectionFailure(snapshot, "host process snapshot unavailable: "+err.Error())
		return nil
	}
	byRoot := attributeOrphanReapCandidates(snap, activeRoots)
	toSignal := s.applyOrphanReapOwnership(ctx, job.TaskID, snap, byRoot, snapshot)
	sort.Slice(toSignal, func(i, j int) bool { return toSignal[i].PID < toSignal[j].PID })
	phaseResult := s.signalOrphanReapCandidatesWithLimit(ctx, job.TaskID, toSignal, snapshot, orphanReapMaxCandidates)
	return phaseResult.errs
}

Fail-closed ownership filter

apps/backend/internal/task/service/resource_cleanup_orphan_reap_ownership.go

The filter blocks a root or candidate when another task owns the workspace, the PID, or an ancestor, and fails closed on any inconclusive check.

Ownership checks
func (s *Service) applyOrphanReapOwnership(ctx context.Context, taskID string, snap []hostProcess, byRoot map[string][]orphanReapCandidate, snapshot *taskResourceCleanupSnapshot) []orphanReapCandidate {
	ppidByPID := make(map[int]int, len(snap))
	for _, p := range snap {
		ppidByPID[p.PID] = p.PPID
	}
	protected, protectedInconclusive := orphanReapProtectedPIDs(ppidByPID)
	if protectedInconclusive {
		s.skipEveryOrphanReapRoot(snapshot, byRoot, "ownership check inconclusive: could not resolve the backend's own ancestry")
		return nil
	}
	otherExecutors, execErr := s.executors.ListExecutorsRunning(ctx)
	if execErr != nil {
		s.skipEveryOrphanReapRoot(snapshot, byRoot, "ownership check failed: "+execErr.Error())
		return nil
	}
	otherSessions, sessErr := s.sessions.ListLiveWorkspaceSessions(ctx)
	if sessErr != nil {
		s.skipEveryOrphanReapRoot(snapshot, byRoot, "ownership check failed: "+sessErr.Error())
		return nil
	}
	for root, candidates := range byRoot {
		if owner, blocked := orphanReapFindOverlap(otherSessionPaths, root); blocked {
			s.recordOrphanReapRootSkip(snapshot, root, "workspace not exclusively owned: session of task "+owner)
			continue
		}
		if owner, blocked := orphanReapFindContainment(liveWorktreeRoots, root); blocked {
			s.recordOrphanReapRootSkip(snapshot, root, "another task's live recorded execution occupies this workspace: task "+owner)
			continue
		}
		for _, cand := range candidates {
			toSignal = s.applyOrphanReapPerCandidateOwnership(snapshot, taskID, cand, protected, ppidByPID, localPIDOwner, toSignal)
		}
	}
	return toSignal
}

SIGTERM to SIGKILL escalation

apps/backend/internal/task/service/resource_cleanup_orphan_reap_signal.go

The phase sends SIGTERM to all candidates, waits 2 seconds, sends SIGKILL to survivors, and re-verifies cwd before each signal.

Escalation with re-verify
func (s *Service) signalOrphanReapCandidatesWithLimit(ctx context.Context, taskID string, candidates []orphanReapCandidate, snapshot *taskResourceCleanupSnapshot, maxCandidates int) orphanReapSignalPhaseResult {
	verifier := s.orphanReapVerifier
	if verifier == nil {
		verifier = defaultOrphanReapVerifier()
	}
	signaler := s.orphanReapSignaler
	if signaler == nil {
		signaler = realOrphanReapSignaler{}
	}
	if ctx.Err() != nil {
		return orphanReapSignalPhaseResult{errs: []error{errOrphanReapCancelledMidPhase}}
	}
	termResult := s.sendOrphanReapSigterms(ctx, taskID, candidates, snapshot, verifier, signaler, maxCandidates)
	pending := termResult.pending
	if len(pending) == 0 {
		return result
	}
	graceTimer := time.NewTimer(orphanReapGraceDelay)
	select {
		case <-ctx.Done():
			result.errs = s.recordOrphanReapCancelledMidPhase(snapshot, taskID, pending)
			return result
		case <-graceTimer.C:
		}
	killPending := s.sendOrphanReapSigkills(ctx, taskID, pending, snapshot, verifier, signaler)
	settleTimer := time.NewTimer(orphanReapSettleDelay)
	select {
		case <-ctx.Done():
			result.errs = s.recordOrphanReapCancelledMidPhase(snapshot, taskID, killPending)
			return result
		case <-settleTimer.C:
		}
	result.errs = s.resolveOrphanReapSurvivors(ctx, taskID, killPending, snapshot, verifier, signaler)
	return result
}
Re-verify before signal
func orphanReapReverifyInsideRoot(ctx context.Context, verifier orphanReapVerifier, pid int, root string) bool {
	if !orphanReapRootIsAbsent(root) {
		return false
	}
	verifyCtx, cancel := context.WithTimeout(ctx, orphanReapVerifyTimeout)
	defer cancel()
	cwd, err := verifier.VerifyCwd(verifyCtx, pid)
	if err != nil || cwd == "" {
		return false
	}
	if !orphanReapRootIsAbsent(root) {
		return false
	}
	return orphanReapPathWithinRoot(root, cwd)
}

Effective workspace path for ownership

apps/backend/internal/task/repository/sqlite/session.go

The ownership check needs the effective workspace path that prefers the linked environment path over the stale session column.

New repository method
func (r *Repository) ListLiveWorkspaceSessions(ctx context.Context) ([]*models.TaskSession, error) {
	ctx, span := tracing.Tracer("kandev-db").Start(ctx, "db.ListLiveWorkspaceSessions")
	defer span.End()
	rows, err := r.ro.QueryContext(ctx,
		`SELECT `+taskSessionSelectCols+` `+taskSessionFromClause+
			` WHERE ts.state IN ('CREATED', 'STARTING', 'RUNNING', 'IDLE', 'WAITING_FOR_INPUT')`,
	)
	if err != nil {
		return nil, err
	}
	defer func() { _ = rows.Close() }()
	return r.scanTaskSessions(ctx, rows)
}
Interface
type SessionRepository interface {
	ListActiveTaskSessions(ctx context.Context) ([]*models.TaskSession, error)
	ListActiveTaskSessionsByTaskID(ctx context.Context, taskID string) ([]*models.TaskSession, error)
	ListLiveWorkspaceSessions(ctx context.Context) ([]*models.TaskSession, error)
}

Host snapshot and path matching

apps/backend/internal/task/service/resource_cleanup_orphan_reap_host_parse.go

The phase matches processes by working directory on a component boundary and attributes a process to the deepest root.

Attribution and path check
func attributeOrphanReapCandidates(snapshot []hostProcess, roots []string) map[string][]orphanReapCandidate {
	ordered := append([]string(nil), roots...)
	sortByDescendingLength(ordered)
	byRoot := make(map[string][]orphanReapCandidate)
	for _, proc := range snapshot {
		if proc.Cwd == "" {
			continue
		}
		for _, root := range ordered {
			if orphanReapPathWithinRoot(root, proc.Cwd) {
				byRoot[root] = append(byRoot[root], orphanReapCandidate{hostProcess: proc, Root: root})
				break
			}
		}
	}
	return byRoot
}
func orphanReapPathWithinRoot(root, candidate string) bool {
	if root == "" || candidate == "" {
		return false
	}
	if candidate == root {
		return true
	}
	prefix := root
	if !strings.HasSuffix(prefix, string(filepath.Separator)) {
		prefix += string(filepath.Separator)
	}
	return strings.HasPrefix(candidate, prefix)
}
Linux cwd with deleted suffix
func readProcCwd(pid int) (string, error) {
	target, err := os.Readlink("/proc/" + strconv.Itoa(pid) + "/cwd")
	if err != nil {
		return "", err
	}
	return trimProcCwdDeletedSuffix(target), nil
}
func trimProcCwdDeletedSuffix(target string) string {
	return strings.TrimSuffix(target, procDeletedSuffix)
}

Data and storage

Three new fields on the durable cleanup snapshot. No schema change. Roots are append-only, records are keyed by PID.

FieldTypeNotes
orphan_reap_roots[]stringresolved paths confirmed absent, one per removed worktree or task dir, never removed once added
orphan_reap_records[]orphanReapCandidateRecordone outcome per PID: pid, cwd, root, command, outcome (terminated, killed, skipped, survived), reason
orphan_reap_skips[]orphanReapSkipRecordroot-level or phase-level skip with no PID to attach reason to, append-only

Risk

6 / 10 Medium
1 low5 medium10 high

Why this score

  • The phase sends SIGTERM and SIGKILL to host PIDs it did not launch, so a wrong ownership check can stop another task.
  • Fail-closed checks, re-verification before each signal, and protected PID set limit blast radius, but the signal is still irreversible.
  • Extensive tests cover ownership, signalling, and platform parsing, and the phase is gated on clean stops and skips on Windows.

Trade-offs and review notes

Where to look first

  1. Check applyOrphanReapOwnership: every inconclusive path fails closed at the narrowest scope and logs at Warn.
  2. Check signalOrphanReapCandidatesWithLimit: re-verify before each signal, batch SIGTERM, and handle cancellation mid-phase.
  3. Check ListLiveWorkspaceSessions: effective path uses COALESCE and includes IDLE, unlike ListActiveTaskSessions.
  4. Check host snapshot readers: Linux strips (deleted) suffix, Darwin merges lsof and ps, Windows skips the phase.