scripts/internal/agent-tasks/process-tree.ps1
|
# Cross-platform graceful process-TREE kill, shared by the isolated-task supervisor (the async watchdog) # AND the inline reviewer spawn (continuous-co-review/worktree-reviewer.ps1) - ONE kill mechanism, not two # divergent ones (T091/FR-037, "consolidate on the supervisor"). The reviewer (claude -p / codex exec) may # be a GRANDCHILD of the spawned process, so a single-pid kill orphans it; this snapshots the descendant # tree and does graceful SIGTERM -> flush window -> SIGKILL so an in-flight finding (R1) can flush first. Set-StrictMode -Version Latest function Get-SpecrewProcessTreeDescendants { # Snapshot the full descendant tree BEFORE any kill - so a grandchild that the kill would re-parent # is still in the snapshot. Returns descendants only (NOT $RootPid), deepest-first. Cross-platform: # one CIM parent-map pass on Windows (T091: the dead-root belt needs a REAL snapshot there too), # BFS via `pgrep -P` on Unix. param([Parameter(Mandatory)][int]$RootPid) $ordered = [System.Collections.Generic.List[int]]::new() if ($IsWindows) { # One enumeration, then BFS the parent map in memory (N processes, not N queries). $byParent = @{} try { foreach ($p in (Get-CimInstance Win32_Process -ErrorAction Stop | Select-Object ProcessId, ParentProcessId)) { $pp = [int]$p.ParentProcessId if (-not $byParent.ContainsKey($pp)) { $byParent[$pp] = [System.Collections.Generic.List[int]]::new() } $byParent[$pp].Add([int]$p.ProcessId) } } catch { return , (@()) } $frontier = @($RootPid) while ($frontier.Count -gt 0) { $next = [System.Collections.Generic.List[int]]::new() foreach ($p in $frontier) { if ($byParent.ContainsKey([int]$p)) { foreach ($k in $byParent[[int]$p]) { if (-not $ordered.Contains($k)) { $ordered.Add($k); $next.Add($k) } } } } $frontier = $next.ToArray() } $ordered.Reverse() return , ($ordered.ToArray()) } $frontier = @($RootPid) while ($frontier.Count -gt 0) { $next = [System.Collections.Generic.List[int]]::new() foreach ($p in $frontier) { $kids = @() try { $raw = & pgrep -P $p 2>$null $kids = @($raw | ForEach-Object { $i = 0; if ([int]::TryParse(($_.ToString().Trim()), [ref]$i)) { $i } } | Where-Object { $_ -gt 0 }) } catch { $kids = @() } foreach ($k in $kids) { if (-not $ordered.Contains($k)) { $ordered.Add($k); $next.Add($k) } } } $frontier = $next.ToArray() } $ordered.Reverse() return , ($ordered.ToArray()) } function Initialize-SpecrewProcessContainmentRuntime { # T091 root-cause fix: the Job Object P/Invoke type compiles on FIRST use (Add-Type, 1-3s). If that # first use happens AFTER the child spawn (inside New-SpecrewProcessContainment), the child can fork # a grandchild DURING the compile - before job assignment - and escape containment (the empirically # caught pre-assignment window). Call this BEFORE spawning: the compile happens while no child # exists, so assignment lands within ms of the spawn. No-op on Unix and on repeat calls. if ($IsWindows) { try { Initialize-SpecrewJobObjectType } catch { $null = $_ } } } function Stop-SpecrewProcessTree { # Kill $RootPid AND its descendant(s), gracefully (SIGTERM -> flush) then hard (SIGKILL). Cross-platform. param([Parameter(Mandatory)][int]$RootPid, [int]$GraceSeconds = 5) if ($IsWindows) { $snapshot = @(Get-SpecrewProcessTreeDescendants -RootPid $RootPid) # while the root is alive try { & taskkill /PID $RootPid /T 2>&1 | Out-Null } catch { $null = $_ } # graceful close if ($GraceSeconds -gt 0) { Start-Sleep -Seconds $GraceSeconds } try { & taskkill /PID $RootPid /T /F 2>&1 | Out-Null } catch { $null = $_ } # force the tree # Dead-root belt (T091): /T cannot resolve the tree of an already-dead root, so a descendant # whose parent died first would survive it. The pre-kill snapshot still names it - force it. foreach ($p in $snapshot) { try { Stop-Process -Id $p -Force -ErrorAction SilentlyContinue } catch { $null = $_ } } return } # Unix. `kill` is a Stop-Process ALIAS in PowerShell, so resolve the Application binary explicitly. $killBin = Get-Command -Name 'kill' -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 if (-not $killBin) { foreach ($p in (@(Get-SpecrewProcessTreeDescendants -RootPid $RootPid) + @($RootPid))) { try { Stop-Process -Id $p -Force -ErrorAction SilentlyContinue } catch { $null = $_ } } return } # The kill order matters because of a spawn race: a descendant (the reviewer) can appear just before the # deadline, AND once the root dies its orphan re-parents to init and falls out of `pgrep -P` reach. So: # Pass 1 - graceful SIGTERM of the current descendants (the reviewer's flush window); the ROOT stays alive. foreach ($p in (Get-SpecrewProcessTreeDescendants -RootPid $RootPid)) { try { & $killBin.Source -TERM $p 2>$null } catch { $null = $_ } } if ($GraceSeconds -gt 0) { Start-Sleep -Seconds $GraceSeconds } # Pass 2 - RE-enumerate while the root is still alive (catches a descendant the first snapshot raced past) + SIGKILL. foreach ($p in (Get-SpecrewProcessTreeDescendants -RootPid $RootPid)) { try { & $killBin.Source -KILL $p 2>$null } catch { $null = $_ } } # Pass 3 - SIGKILL the root LAST (killing it earlier would orphan a late descendant out of pgrep's view). try { & $killBin.Source -KILL $RootPid 2>$null } catch { $null = $_ } } # --- T100/FR-039 (design N2, Option A): OS-native atomic containment ------------------------------ # The poll-then-kill walk above is a best-effort SNAPSHOT - a grandchild forked between snapshot and # kill escapes it (the 1h12m field hang). These primitives make the tree die by OS construct instead: # Windows - a Job Object with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: every descendant is IN the job by # inheritance; TerminateJobObject is atomic, and if the supervisor itself dies the closed # handle kills the whole job (no orphan even on supervisor crash - by construction). # Unix - the child is spawned as its own session/process-group leader (setsid exec, see the # supervisor's spawn); `kill -- -PGID` then reaches every descendant that did not itself # setsid. The snapshot walk stays as the belt-and-suspenders second pass. A cgroup kill # would also catch setsid escapees, but cgroup-v2 delegation is not available to an # unprivileged supervisor on stock WSL/dev boxes - documented seam, not built. # Stop-SpecrewProcessTree stays the universal fallback (mode 'tree-kill') when neither is available. function Initialize-SpecrewJobObjectType { # Compile the kernel32 P/Invoke surface once per process. Windows-only caller. if ('SpecrewJobNative' -as [type]) { return } Add-Type -TypeDefinition @' using System; using System.Runtime.InteropServices; public static class SpecrewJobNative { [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] public static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string lpName); [DllImport("kernel32.dll", SetLastError = true)] public static extern bool SetInformationJobObject(IntPtr hJob, int infoClass, IntPtr lpInfo, uint cbInfo); [DllImport("kernel32.dll", SetLastError = true)] public static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess); [DllImport("kernel32.dll", SetLastError = true)] public static extern bool TerminateJobObject(IntPtr hJob, uint uExitCode); [DllImport("kernel32.dll", SetLastError = true)] public static extern bool CloseHandle(IntPtr hObject); [DllImport("kernel32.dll", SetLastError = true)] public static extern IntPtr OpenProcess(uint dwDesiredAccess, bool bInheritHandle, int dwProcessId); [StructLayout(LayoutKind.Sequential)] public struct JOBOBJECT_BASIC_LIMIT_INFORMATION { public long PerProcessUserTimeLimit; public long PerJobUserTimeLimit; public uint LimitFlags; public UIntPtr MinimumWorkingSetSize; public UIntPtr MaximumWorkingSetSize; public uint ActiveProcessLimit; public UIntPtr Affinity; public uint PriorityClass; public uint SchedulingClass; } [StructLayout(LayoutKind.Sequential)] public struct IO_COUNTERS { public ulong ReadOperationCount; public ulong WriteOperationCount; public ulong OtherOperationCount; public ulong ReadTransferCount; public ulong WriteTransferCount; public ulong OtherTransferCount; } [StructLayout(LayoutKind.Sequential)] public struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION { public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation; public IO_COUNTERS IoInfo; public UIntPtr ProcessMemoryLimit; public UIntPtr JobMemoryLimit; public UIntPtr PeakProcessMemoryUsed; public UIntPtr PeakJobMemoryUsed; } public const int JobObjectExtendedLimitInformation = 9; public const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000; public const uint PROCESS_SET_QUOTA = 0x0100; public const uint PROCESS_TERMINATE = 0x0001; } '@ } function Get-SpecrewProcessGroupId { # Unix: the PGID of $TargetPid via `ps` (POSIX; present on Linux/WSL/macOS). $null on any failure. param([Parameter(Mandatory)][int]$TargetPid) if ($IsWindows) { return $null } try { $raw = (& ps -o pgid= -p $TargetPid 2>$null | Out-String).Trim() $pgid = 0 if ([int]::TryParse($raw, [ref]$pgid) -and $pgid -gt 0) { return $pgid } } catch { $null = $_ } return $null } function New-SpecrewProcessContainment { <# Wrap a just-spawned child in OS-native containment. Returns a descriptor consumed by Stop-/Close-SpecrewProcessContainment: @{ mode; child_pid; child_pgid; job_handle } mode: 'job-object' (Win, atomic) | 'pgid' (Unix, child is its own group leader) | 'tree-kill' (fallback: snapshot walk only - containment could not be established). NEVER throws - a containment failure degrades to 'tree-kill' so the spawn still proceeds (the walk is the iter-009 behavior; containment is strictly additive). #> param([Parameter(Mandatory)][int]$ChildPid) if ($IsWindows) { $job = [IntPtr]::Zero $proc = [IntPtr]::Zero try { Initialize-SpecrewJobObjectType $job = [SpecrewJobNative]::CreateJobObject([IntPtr]::Zero, $null) if ($job -eq [IntPtr]::Zero) { throw 'CreateJobObject failed' } # KILL_ON_JOB_CLOSE: the last handle closing (incl. supervisor death) kills the whole job. # VALUE-TYPE TRAP: `$info.BasicLimitInformation.LimitFlags = ...` sets the flag on a COPY # of the nested struct (silently lost - verified: flags stay 0). Copy out, set, copy back. $info = New-Object SpecrewJobNative+JOBOBJECT_EXTENDED_LIMIT_INFORMATION $basic = $info.BasicLimitInformation $basic.LimitFlags = [SpecrewJobNative]::JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE $info.BasicLimitInformation = $basic $size = [System.Runtime.InteropServices.Marshal]::SizeOf($info) $buf = [System.Runtime.InteropServices.Marshal]::AllocHGlobal($size) try { [System.Runtime.InteropServices.Marshal]::StructureToPtr($info, $buf, $false) if (-not [SpecrewJobNative]::SetInformationJobObject($job, [SpecrewJobNative]::JobObjectExtendedLimitInformation, $buf, [uint32]$size)) { throw 'SetInformationJobObject failed' } } finally { [System.Runtime.InteropServices.Marshal]::FreeHGlobal($buf) } $proc = [SpecrewJobNative]::OpenProcess(([SpecrewJobNative]::PROCESS_SET_QUOTA -bor [SpecrewJobNative]::PROCESS_TERMINATE), $false, $ChildPid) if ($proc -eq [IntPtr]::Zero) { throw "OpenProcess($ChildPid) failed" } if (-not [SpecrewJobNative]::AssignProcessToJobObject($job, $proc)) { throw 'AssignProcessToJobObject failed' } # Children the harness spawns from here on are in the job by inheritance. The only escape # window is a grandchild forked BEFORE this assignment (~ms after spawn, vs pwsh's ~100ms+ # boot) - covered by the belt-and-suspenders walk in Stop-SpecrewProcessContainment. return [pscustomobject]@{ mode = 'job-object'; child_pid = $ChildPid; child_pgid = $null; job_handle = $job; degraded_reason = $null } } catch { $why = $_.Exception.Message if ($job -ne [IntPtr]::Zero) { try { $null = [SpecrewJobNative]::CloseHandle($job) } catch { $null = $_ } } # degraded_reason: WHY containment fell back - recorded so a field report can distinguish # "job objects unavailable here" from a code defect (never silently degrade). return [pscustomobject]@{ mode = 'tree-kill'; child_pid = $ChildPid; child_pgid = $null; job_handle = $null; degraded_reason = $why } } finally { if ($proc -ne [IntPtr]::Zero) { try { $null = [SpecrewJobNative]::CloseHandle($proc) } catch { $null = $_ } } } } # Unix: the SPAWN (setsid exec, supervisor-side) made the child a session/group leader; here we # only VERIFY it. pgid == pid proves leadership; anything else (setsid absent, probe failure, # child already exited) degrades honestly to the snapshot walk. $pgid = Get-SpecrewProcessGroupId -TargetPid $ChildPid if ($null -ne $pgid -and $pgid -eq $ChildPid) { return [pscustomobject]@{ mode = 'pgid'; child_pid = $ChildPid; child_pgid = $pgid; job_handle = $null; degraded_reason = $null } } return [pscustomobject]@{ mode = 'tree-kill'; child_pid = $ChildPid; child_pgid = $null; job_handle = $null; degraded_reason = 'not-a-group-leader-or-pgid-probe-failed' } } function Stop-SpecrewProcessContainment { # The ONE kill: graceful (flush window for an in-flight finding, R1) -> atomic OS kill -> the # snapshot walk as the final sweep. Safe on any descriptor state; never throws. param([Parameter(Mandatory)][psobject]$Containment, [int]$GraceSeconds = 5) $rootPid = [int]$Containment.child_pid if ($Containment.mode -eq 'job-object' -and $Containment.job_handle -and ($Containment.job_handle -ne [IntPtr]::Zero)) { # Snapshot the descendants WHILE THE ROOT IS ALIVE: a grandchild forked in the pre-assignment # window is outside the job AND unreachable via /T once its parent dies - only a live snapshot # names it (the empirically caught escape; see Initialize-SpecrewProcessContainmentRuntime). $snapshot = @(Get-SpecrewProcessTreeDescendants -RootPid $rootPid) # Graceful close first (taskkill /T posts WM_CLOSE / console ctrl to the tree), then the # ATOMIC job kill - every job member dies in one kernel call, no walk, no race. try { & taskkill /PID $rootPid /T 2>&1 | Out-Null } catch { $null = $_ } if ($GraceSeconds -gt 0) { Start-Sleep -Seconds $GraceSeconds } try { $null = [SpecrewJobNative]::TerminateJobObject($Containment.job_handle, 137) } catch { $null = $_ } # Belt-and-suspenders for pre-assignment escapees: tree-kill while a live root can resolve it, # then force every snapshot survivor individually (a dead root resolves no tree). try { & taskkill /PID $rootPid /T /F 2>&1 | Out-Null } catch { $null = $_ } foreach ($p in $snapshot) { try { Stop-Process -Id $p -Force -ErrorAction SilentlyContinue } catch { $null = $_ } } return } if ($Containment.mode -eq 'pgid' -and $Containment.child_pgid) { $killBin = Get-Command -Name 'kill' -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 if ($killBin) { $pgid = [int]$Containment.child_pgid # Pre-kill snapshot (same rationale as the job branch): a descendant that re-setsid'd out # of the group is invisible to the group signal AND to a post-kill walk of a dead root. $snapshot = @(Get-SpecrewProcessTreeDescendants -RootPid $rootPid) # Graceful group TERM (the whole session flushes), grace, then group KILL - one signal # reaches every group member atomically (no snapshot race for non-setsid descendants). try { & $killBin.Source -TERM -- "-$pgid" 2>$null } catch { $null = $_ } if ($GraceSeconds -gt 0) { Start-Sleep -Seconds $GraceSeconds } try { & $killBin.Source -KILL -- "-$pgid" 2>$null } catch { $null = $_ } # Belt: force any snapshot survivor (group escapees), root-last semantics preserved by the # group KILL above having already taken the root. foreach ($p in $snapshot) { try { Stop-Process -Id $p -Force -ErrorAction SilentlyContinue } catch { $null = $_ } } return } } # Fallback ('tree-kill' / no handle / no kill binary): iter-009 snapshot walk. Stop-SpecrewProcessTree -RootPid $rootPid -GraceSeconds $GraceSeconds } function Close-SpecrewProcessContainment { # Finally-time straggler reap after the child ENDED (either way): a background process the # harness left behind must not outlive the run ("no orphans" applies to clean exits too). # Windows: closing the last job handle IS the reap (KILL_ON_JOB_CLOSE). # Unix: one silent group-KILL sweep; pgid reuse in the seconds-scale window is the same # (accepted) exposure the snapshot walk already has. param([Parameter(Mandatory)][psobject]$Containment) if ($Containment.mode -eq 'job-object' -and $Containment.job_handle -and ($Containment.job_handle -ne [IntPtr]::Zero)) { try { $null = [SpecrewJobNative]::CloseHandle($Containment.job_handle) } catch { $null = $_ } return } if ($Containment.mode -eq 'pgid' -and $Containment.child_pgid) { $killBin = Get-Command -Name 'kill' -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 if ($killBin) { try { & $killBin.Source -KILL -- ("-{0}" -f [int]$Containment.child_pgid) 2>$null } catch { $null = $_ } } } } |