scripts/internal/agent-tasks/isolated-task-supervisor.ps1

<#
.SYNOPSIS
    The detached supervisor body for the isolated-task launcher (the "watchdog" role).
 
.DESCRIPTION
    This script is the DETACHED process `Start-SpecrewIsolatedTask` fires via `pwsh -File`. It runs
    to completion EVEN AFTER its parent (the Stop-hook provider) exits - that is the whole point of
    the launcher: the expensive task runs off the provider's budget.
 
    It reads a single job JSON (no -ArgumentList quoting hell), then:
      1. Spawns the harness `-Command` IN the materialized worktree (cwd = worktree), redirecting
         the child's stdio to FILES (the child's stdout IS the reviewer result -> result.out). The
         redirect at every hop is the load-bearing T076 cross-platform-detachment rule.
      2. Enforces its OWN timeout in plain PowerShell ($child.HasExited poll + Stop-Process -Force
         on deadline) - NOT an OS process-group flag (most portable + testable). On timeout it kills
         the child PROCESS TREE (the harness may have spawned its own reviewer subprocess).
      3. Writes a terminal status.json AND read-modify-writes the registry's status field to a
         terminal value (running -> done | timed-out | failed).
      4. DISPOSES the worktree in a `finally` so a timed-out/killed run STILL deletes it - orphan
         safety by construction (this feature exists to prevent the orphaned-worktree/zombie leak).
 
    Only `disposition: discard` is implemented (review). The job carries access/disposition for the
    future seams; this body asserts the built path and disposes by discard.
 
.NOTES
    F-184 footprint: NONE. PowerShell 7.x. Standalone (NOT dot-sourced) so the launcher can be
    dot-sourced for its functions without ever running this supervisor loop.
#>

param(
    [Parameter(Mandatory)][string]$JobPath
)

$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest

# --- helpers (self-contained, except the co-located process-tree.ps1 sibling dot-sourced below) ---
function Write-SupervisorJson {
    param([string]$Path, $Object)
    $tmp = '{0}.{1}.tmp' -f $Path, ([guid]::NewGuid().ToString('N'))
    $utf8NoBom = [System.Text.UTF8Encoding]::new($false)
    [System.IO.File]::WriteAllText($tmp, ($Object | ConvertTo-Json -Depth 8), $utf8NoBom)
    Move-Item -LiteralPath $tmp -Destination $Path -Force
}

function Update-SupervisorRegistryStatus {
    param([string]$RegistryPath, [string]$Status, [hashtable]$Extra = @{})
    if (-not $RegistryPath -or -not (Test-Path -LiteralPath $RegistryPath -PathType Leaf)) { return }
    try {
        $reg = Get-Content -LiteralPath $RegistryPath -Raw | ConvertFrom-Json
        $reg | Add-Member -NotePropertyName 'status' -NotePropertyValue $Status -Force
        foreach ($k in $Extra.Keys) { $reg | Add-Member -NotePropertyName $k -NotePropertyValue $Extra[$k] -Force }
        Write-SupervisorJson -Path $RegistryPath -Object $reg
    }
    catch { $null = $_ }
}

# --- co-located process-tree kill helper (T091/FR-037, the ONE watchdog kill) --------------------
# The supervisor stays standalone (never dot-SOURCED itself, so the launcher can dot-source its own
# functions without running this loop), but it MAY dot-source this co-located sibling - the FileList
# always co-deploys process-tree.ps1 in this dir. Require it (throw, don't silently degrade the kill).
$processTreeHelper = Join-Path $PSScriptRoot 'process-tree.ps1'
if (-not (Test-Path -LiteralPath $processTreeHelper -PathType Leaf)) {
    throw "isolated-task-supervisor: missing co-located process-tree helper '$processTreeHelper'."
}
. $processTreeHelper

# --- load the job spec ---------------------------------------------------------------------------
$job = Get-Content -LiteralPath $JobPath -Raw | ConvertFrom-Json
$worktree = $job.worktree_path
$runDir = $job.run_dir
$statusPath = $job.status_path
$resultPath = $job.result_path
$resultErrPath = $job.result_err
$registryPath = $job.registry_path
$timeoutSec = [int]$job.timeout_sec
$command = $job.command

$terminalStatus = 'failed'
$terminalReason = 'supervisor-error'   # T100/FR-039: WHY the run ended, recorded on every terminal write
$childPid = $null
$childExit = $null
$containment = $null

try {
    # Built path only: review = read-only + discard. (Job may carry future seams; assert here.)
    if ($job.access -ne 'read-only') { throw "supervisor: access '$($job.access)' not implemented" }
    if ($job.disposition -ne 'discard') { throw "supervisor: disposition '$($job.disposition)' not implemented" }

    # 1) Spawn the harness IN the worktree, child stdio -> files (stdout = the reviewer result).
    # CROSS-PLATFORM QUOTING: do NOT pass the command via `pwsh -Command <string>` - an arbitrary
    # command string loses its embedded quotes when Start-Process joins -ArgumentList on Linux
    # (the WSL run proved this corrupts the child's script). Instead write the command to a
    # harness.ps1 FILE and run `pwsh -File <path>`: a single file-path argument has no embedded
    # quotes and survives Start-Process's arg-join identically on both platforms. This keeps the
    # PROVEN detachment mechanism intact (Start-Process + file stdio redirect) and changes only
    # WHAT is passed. Bonus: multi-line commands + the harness-as-evidence come for free.
    # -WorkingDirectory makes the reviewer see the materialized snapshot as its repo root, so
    # `$PWD` inside the harness is the worktree.
    $harnessPath = Join-Path $runDir 'harness.ps1'
    $utf8NoBom = [System.Text.UTF8Encoding]::new($false)
    [System.IO.File]::WriteAllText($harnessPath, $command, $utf8NoBom)
    $spArgs = @{
        FilePath               = 'pwsh'
        ArgumentList           = @('-NoProfile', '-NonInteractive', '-File', $harnessPath)
        WorkingDirectory       = $worktree
        PassThru               = $true
        RedirectStandardOutput = $resultPath
        RedirectStandardError  = $resultErrPath
    }
    if ($IsWindows) { $spArgs.WindowStyle = 'Hidden' }   # Windows-only; omit on Unix
    # Compile the containment runtime BEFORE the spawn (T091 root-cause): first-use Add-Type takes
    # seconds; paying it after Start-Process opens the pre-assignment escape window.
    Initialize-SpecrewProcessContainmentRuntime
    if (-not $IsWindows) {
        # T100/FR-039 (N2): make the harness its OWN session/process-group leader so one group signal
        # (`kill -- -PGID`) reaches the whole reviewer tree atomically. util-linux setsid EXECS the
        # program in place here (it only forks when the caller is already a group leader, and a
        # .NET-spawned child never is), so the PID Start-Process returns stays the harness pwsh - the
        # containment probe below VERIFIES leadership (pgid==pid) and degrades to the snapshot walk
        # if this box's setsid behaved differently. macOS has no setsid binary -> plain spawn.
        $setsidBin = Get-Command -Name 'setsid' -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
        if ($setsidBin) {
            $spArgs.FilePath = $setsidBin.Source
            $spArgs.ArgumentList = @('pwsh') + $spArgs.ArgumentList
        }
    }
    $child = Start-Process @spArgs
    $childPid = $child.Id

    # T100: OS-native containment (Job Object w/ KILL_ON_JOB_CLOSE on Windows; PGID verification on
    # Unix; 'tree-kill' fallback). Then record the child pids + mode onto the pending registry - the
    # session-scoped pidfile the SessionStart reaper reads, so even a DEAD supervisor's reviewer tree
    # stays killable via the recorded pgid (Unix) / is already dead via the closed job handle (Win).
    $containment = New-SpecrewProcessContainment -ChildPid $childPid
    Update-SupervisorRegistryStatus -RegistryPath $registryPath -Status 'running' -Extra @{
        child_pid                   = $childPid
        child_pgid                  = $containment.child_pgid
        containment                 = $containment.mode
        containment_degraded_reason = $containment.degraded_reason
    }

    # 2) Own timeout/kill loop (plain PowerShell - portable + testable).
    $deadline = (Get-Date).AddSeconds($timeoutSec)
    $timedOut = $false
    while (-not $child.HasExited) {
        if ((Get-Date) -ge $deadline) {
            # Kill the child PROCESS TREE - the reviewer (claude -p / codex exec) is a GRANDCHILD of
            # the harness pwsh, so a single-pid kill ORPHANS it (T091/FR-037, WSL-gated). T100 makes
            # the kill OS-native-atomic: TerminateJobObject (Win) / group signal (Unix), graceful
            # TERM -> flush window -> hard kill, with the snapshot walk as the final sweep.
            Stop-SpecrewProcessContainment -Containment $containment -GraceSeconds 5
            $timedOut = $true
            break
        }
        Start-Sleep -Milliseconds 100
    }
    Start-Sleep -Milliseconds 300   # let the exit/kill settle

    $childAlive = $null -ne (Get-Process -Id $child.Id -ErrorAction SilentlyContinue)
    if ($timedOut) {
        $terminalStatus = 'timed-out'
        $terminalReason = 'hard-timeout'
    }
    else {
        $childExit = $child.ExitCode
        $terminalStatus = ($childExit -eq 0) ? 'done' : 'failed'
        $terminalReason = ($childExit -eq 0) ? 'completed' : 'child-exit-nonzero'
    }

    # 3) Terminal status.json (the reviewer's result is at result.out, captured by the redirect).
    $status = [ordered]@{
        schema_version  = '1.0'
        run_id          = $job.run_id
        status          = $terminalStatus
        terminal_reason = $terminalReason
        timed_out       = $timedOut
        child_pid       = $childPid
        child_pgid      = $containment.child_pgid
        containment     = $containment.mode
        child_exit      = $childExit
        child_alive     = $childAlive
        result_path     = $resultPath
        result_err      = $resultErrPath
        finished_at     = (Get-Date).ToUniversalTime().ToString('o')
    }
    Write-SupervisorJson -Path $statusPath -Object $status
}
catch {
    $terminalStatus = 'failed'
    $terminalReason = 'supervisor-error'
    try {
        Write-SupervisorJson -Path $statusPath -Object ([ordered]@{
                schema_version  = '1.0'
                run_id          = $job.run_id
                status          = 'failed'
                terminal_reason = $terminalReason
                error           = $_.Exception.Message
                child_pid       = $childPid
                finished_at     = (Get-Date).ToUniversalTime().ToString('o')
            })
    }
    catch { $null = $_ }
}
finally {
    # T100: straggler reap BEFORE the worktree delete - a background process the harness left behind
    # (clean exit included) dies here (Win: the closing job handle IS the reap via KILL_ON_JOB_CLOSE;
    # Unix: one silent group-KILL sweep), so nothing can hold the worktree open or outlive the run.
    if ($null -ne $containment) {
        try { Close-SpecrewProcessContainment -Containment $containment } catch { $null = $_ }
    }
    # 4) DISPOSE in a finally - even a timed-out/killed/failed run deletes the worktree. This is the
    # orphan-safety-by-construction guarantee. (discard = delete; merge/preserve are seams.)
    if ($worktree -and (Test-Path -LiteralPath $worktree)) {
        Remove-Item -LiteralPath $worktree -Recurse -Force -ErrorAction SilentlyContinue
    }
    # Mark the registry terminal (read-modify-write; running -> terminal). The child containment
    # fields are RE-ASSERTED here (belt for the launcher's post-spawn merge window, finding f1): if a
    # racing parent write clobbered the mid-run child info, the terminal write restores it, so the
    # reaper's evidence is always complete once the run ends.
    $terminalExtra = @{
        terminal_reason = $terminalReason
        finished_at     = (Get-Date).ToUniversalTime().ToString('o')
        worktree_gone   = (-not ($worktree -and (Test-Path -LiteralPath $worktree)))
    }
    if ($null -ne $childPid) { $terminalExtra.child_pid = $childPid }
    if ($null -ne $containment) {
        $terminalExtra.child_pgid = $containment.child_pgid
        $terminalExtra.containment = $containment.mode
    }
    Update-SupervisorRegistryStatus -RegistryPath $registryPath -Status $terminalStatus -Extra $terminalExtra
}