scripts/internal/continuous-co-review/review-posix-runtime-common.ps1

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

# Starting the isolated PowerShell containment host can exceed five seconds on a loaded hosted runner
# or a low-resource local machine. Keep the handshake separately and explicitly bounded; reviewer
# execution time remains governed by the invocation timeout after the handshake succeeds.
$script:ReviewPosixContainmentHandshakeTimeoutMilliseconds = 15000

# The nested host enforces the reviewer execution deadline. Give that host a separate, bounded
# cleanup window so it can terminate a timed-out reviewer and observe both redirected streams
# without racing the outer containment controller's execution deadline.
$script:ReviewPosixNestedTerminationWaitMilliseconds = 5000
$script:ReviewPosixNestedDrainTimeoutMilliseconds = 5000

if (-not (Get-Command -Name 'Test-ReviewRuntimeProcessSpec' -ErrorAction SilentlyContinue)) {
    . (Join-Path $PSScriptRoot 'review-runtime-contract.ps1')
}
if (-not (Get-Command -Name 'Get-SpecrewProcessTreeDescendants' -ErrorAction SilentlyContinue)) {
    . (Join-Path (Split-Path -Parent $PSScriptRoot) 'agent-tasks/process-tree.ps1')
}

function Resolve-ReviewPosixExecutable {
    param([Parameter(Mandatory)][string]$CommandName)
    $command = Get-Command -Name $CommandName -CommandType Application -All -ErrorAction SilentlyContinue | Select-Object -First 1
    if ($null -eq $command) { throw "runtime-command-unavailable:$CommandName" }
    $path = [IO.Path]::GetFullPath([string]$command.Source)
    if (-not [IO.File]::Exists($path)) { throw "runtime-command-path-missing:$CommandName" }
    return $path
}

function Wait-ReviewPosixReadyFile {
    param(
        [Parameter(Mandatory)][string]$Path,
        [Parameter(Mandatory)][ValidateSet('linux-cgroup', 'process-group')][string]$ExpectedMode,
        [ValidateRange(100, 30000)][int]$TimeoutMilliseconds = $script:ReviewPosixContainmentHandshakeTimeoutMilliseconds,
        [scriptblock]$ReadText = { param($ReadyPath) [IO.File]::ReadAllText($ReadyPath, [Text.UTF8Encoding]::new($false, $true)) },
        [scriptblock]$Sleep = { param($Milliseconds) [Threading.Thread]::Sleep($Milliseconds) }
    )
    $watch = [Diagnostics.Stopwatch]::StartNew()
    while ($watch.ElapsedMilliseconds -lt $TimeoutMilliseconds) {
        if ([IO.File]::Exists($Path)) {
            try {
                $text = & $ReadText $Path
                $ready = $text | ConvertFrom-Json -Depth 8
            }
            catch {
                & $Sleep 20
                continue
            }
            if ($null -eq $ready -or $null -eq $ready.PSObject.Properties['schema_version'] -or
                $null -eq $ready.PSObject.Properties['mode'] -or $null -eq $ready.PSObject.Properties['pid']) { return $null }
            [int]$readyPid = 0
            if (-not [int]::TryParse([string]$ready.pid, [ref]$readyPid) -or
                [string]$ready.schema_version -cne '1.0' -or [string]$ready.mode -cne $ExpectedMode -or $readyPid -lt 1) { return $null }
            return $ready
        }
        & $Sleep 20
    }
    return $null
}

function Test-ReviewPosixProcessDead {
    param([Parameter(Mandatory)][int]$ProcessId)
    try { return $null -eq (Get-Process -Id $ProcessId -ErrorAction SilentlyContinue) }
    catch { return $true }
}

function New-ReviewPosixRuntimePort {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$RuntimeId,
        [Parameter(Mandatory)][ValidateSet('linux', 'macos')][string]$Platform,
        [Parameter(Mandatory)][ValidateSet('cgroup-v2', 'process-group')][string]$Containment,
        [Parameter(Mandatory)][ValidateSet('linux-cgroup', 'process-group')][string]$HostMode,
        [ValidateRange(1, 7200)][int]$TimeoutSeconds = 900,
        [ValidateRange(0, 10)][int]$TerminationGraceSeconds = 5,
        [Parameter(Mandatory)][scriptblock]$CapabilityProbe,
        [Parameter(Mandatory)][scriptblock]$SetupContainment,
        [Parameter(Mandatory)][scriptblock]$VerifyContainment,
        [Parameter(Mandatory)][scriptblock]$StopContainment,
        [Parameter(Mandatory)][scriptblock]$WaitContainmentEmpty,
        [Parameter(Mandatory)][scriptblock]$CleanupContainment,
        [Parameter(Mandatory)][scriptblock]$RecoverContainment
    )
    $validateSpecCommand = ${function:Test-ReviewRuntimeProcessSpec}
    $resolveExecutableCommand = ${function:Resolve-ReviewPosixExecutable}
    $waitReadyCommand = ${function:Wait-ReviewPosixReadyFile}
    $waitOutputDrainsCommand = ${function:Wait-ReviewRuntimeOutputDrains}
    $testDeadCommand = ${function:Test-ReviewPosixProcessDead}
    $writeProgressCommand = ${function:Write-ReviewRuntimeProgressSample}
    $testOutputActivityCommand = ${function:Test-ReviewRuntimeOutputActivity}
    $handshakeTimeoutMilliseconds = $script:ReviewPosixContainmentHandshakeTimeoutMilliseconds
    $nestedTerminationWaitMilliseconds = $script:ReviewPosixNestedTerminationWaitMilliseconds
    $nestedDrainTimeoutMilliseconds = $script:ReviewPosixNestedDrainTimeoutMilliseconds
    $hostPath = Join-Path $PSScriptRoot 'review-posix-process-host.ps1'
    $pwshPath = [IO.Path]::GetFullPath((Get-Process -Id $PID).Path)

    $preflight = {
        param($invocation)
        try {
            $capability = & $CapabilityProbe
            if (-not $capability.ok) { return $capability }
            if ($null -eq $invocation -or -not [IO.Directory]::Exists([string]$invocation.snapshot_path)) {
                return [pscustomobject]@{ ok = $false; reason = "$Platform-runtime-snapshot-missing" }
            }
            return [pscustomobject]@{ ok = $true; reason = "$RuntimeId-ready" }
        }
        catch { return [pscustomobject]@{ ok = $false; reason = ("$Platform-runtime-preflight-failed:" + $_.Exception.Message) } }
    }.GetNewClosure()

    $invoke = {
        param($harness, $invocation, $onStarted, $environment, $progress)
        $process = $null; $descriptor = $null; $readyPath = $null; $started = $false
        $stdoutDrain = $null; $stderrDrain = $null; $containmentVerified = $false
        try {
            if ($null -eq $harness -or -not $harness.PSObject.Properties['build_process'] -or $harness.build_process -isnot [scriptblock]) {
                return [pscustomobject]@{ runtime_outcome = 'launch-failed'; termination_verified = $true; containment = 'unknown'; failure_reason = 'runtime-harness-process-contract-missing'; process_tree_live = $false; output_activity = $false }
            }
            $spec = & $harness.build_process $invocation $environment
            $specValidation = & $validateSpecCommand -Spec $spec -Invocation $invocation
            if (-not $specValidation.valid) {
                return [pscustomobject]@{ runtime_outcome = 'launch-failed'; termination_verified = $true; containment = 'unknown'; failure_reason = ('runtime-process-spec-invalid:' + ($specValidation.errors -join ',')); process_tree_live = $false; output_activity = $false }
            }
            $executable = & $resolveExecutableCommand -CommandName ([string]$spec.command)
            $descriptor = & $SetupContainment $invocation
            if ($null -eq $descriptor) { throw "$Platform-containment-setup-returned-null" }
            $candidateParent = [IO.Path]::GetDirectoryName([IO.Path]::GetFullPath([string]$spec.candidate_result_path))
            $readyPath = Join-Path $candidateParent ('.runtime-ready-' + [guid]::NewGuid().ToString('N') + '.json')

            $startInfo = [Diagnostics.ProcessStartInfo]::new()
            $startInfo.FileName = $pwshPath
            foreach ($argument in @('-NoProfile', '-File', $hostPath, '-Mode', $HostMode, '-ReadyPath', $readyPath)) { [void]$startInfo.ArgumentList.Add($argument) }
            if ($HostMode -ceq 'linux-cgroup') { [void]$startInfo.ArgumentList.Add('-CgroupPath'); [void]$startInfo.ArgumentList.Add([string]$descriptor.path) }
            $startInfo.WorkingDirectory = [string]$spec.working_directory
            $startInfo.UseShellExecute = $false; $startInfo.CreateNoWindow = $true
            $startInfo.RedirectStandardInput = $true; $startInfo.RedirectStandardOutput = $true; $startInfo.RedirectStandardError = $true
            $startInfo.StandardInputEncoding = [Text.UTF8Encoding]::new($false)

            $process = [Diagnostics.Process]::new(); $process.StartInfo = $startInfo
            if (-not $process.Start()) { throw 'posix-runtime-host-start-returned-false' }
            $started = $true
            $stdoutDrain = $process.StandardOutput.BaseStream.CopyToAsync([IO.Stream]::Null)
            $stderrDrain = $process.StandardError.BaseStream.CopyToAsync([IO.Stream]::Null)
            $ready = & $waitReadyCommand -Path $readyPath -ExpectedMode $HostMode -TimeoutMilliseconds $handshakeTimeoutMilliseconds
            if ($null -eq $ready -or [int]$ready.pid -ne $process.Id) { throw "$Platform-containment-handshake-failed" }
            $containmentVerified = [bool](& $VerifyContainment $descriptor $ready $process.Id)
            if (-not $containmentVerified) { throw "$Platform-containment-verification-failed" }

            $containmentId = if ($Containment -ceq 'cgroup-v2') { [string]$descriptor.path } else { [string]$descriptor.pgid }
            $runtimeReceipt = [pscustomobject][ordered]@{
                schema_version = '1.0'; runtime_id = $RuntimeId; platform = $Platform; containment_kind = $Containment
                containment_id = $containmentId; process_id = $process.Id
                process_started_at = $process.StartTime.ToUniversalTime().ToString('o')
            }
            try { $null = & $onStarted $runtimeReceipt }
            catch { throw ('runtime-start-callback-failed:' + $_.Exception.Message) }

            $environmentDelta = [ordered]@{}
            foreach ($key in @($spec.environment_delta.Keys)) { $environmentDelta[[string]$key] = [string]$spec.environment_delta[$key] }
            $effectiveTimeout = [Math]::Min($TimeoutSeconds, [int]$spec.timeout_seconds)
            $payload = [ordered]@{
                executable = $executable; argument_list = @($spec.argument_list); working_directory = [string]$spec.working_directory
                environment_delta = $environmentDelta; prompt_transport = [string]$spec.prompt_transport; stdin_text = $(if ($spec.prompt_transport -ceq 'stdin') { [string]$spec.stdin_text } else { $null })
                timeout_seconds = $effectiveTimeout
                termination_wait_milliseconds = $nestedTerminationWaitMilliseconds
                drain_timeout_milliseconds = $nestedDrainTimeoutMilliseconds
            }
            $process.StandardInput.Write(($payload | ConvertTo-Json -Compress -Depth 12))
            $process.StandardInput.Close()

            $timeoutMilliseconds = ([long]$effectiveTimeout * 1000) + $nestedTerminationWaitMilliseconds + $nestedDrainTimeoutMilliseconds
            $waitWatch = [Diagnostics.Stopwatch]::StartNew()
            & $writeProgressCommand -Progress $progress -CandidateResultPath ([string]$spec.candidate_result_path) -ProcessTreeLive $true
            $exited = $process.HasExited
            while (-not $exited) {
                $remaining = $timeoutMilliseconds - $waitWatch.ElapsedMilliseconds
                if ($remaining -le 0) { $exited = $process.HasExited; break }
                $slice = [int][Math]::Min(5000, [Math]::Max(1, $remaining))
                $exited = $process.WaitForExit($slice)
                if (-not $exited) {
                    & $writeProgressCommand -Progress $progress -CandidateResultPath ([string]$spec.candidate_result_path) -ProcessTreeLive $true
                }
            }
            $exitCode = if ($exited) { $process.ExitCode } else { $null }
            $timedOut = (-not $exited) -or ($exitCode -eq 124)
            & $StopContainment $descriptor $(if ($timedOut) { $TerminationGraceSeconds } else { 0 })
            if (-not $process.HasExited) { [void]$process.WaitForExit(5000) }
            $streamState = & $waitOutputDrainsCommand -StdoutDrain $stdoutDrain -StderrDrain $stderrDrain -TimeoutMilliseconds 5000
            $streamsClosed = $streamState.all_closed
            $containmentEmpty = [bool](& $WaitContainmentEmpty $descriptor 5000)
            $rootDead = & $testDeadCommand -ProcessId $process.Id
            $cleanupVerified = [bool](& $CleanupContainment $descriptor)
            $terminationVerified = $streamsClosed -and $containmentEmpty -and $rootDead -and $cleanupVerified
            $outputActivity = & $testOutputActivityCommand -CandidateResultPath ([string]$spec.candidate_result_path)
            if ($timedOut) {
                return [pscustomobject]@{
                    runtime_outcome = 'timed-out'; termination_verified = $terminationVerified; containment = $(if ($containmentVerified) { 'verified' } else { 'unknown' })
                    failure_reason = $(if ($terminationVerified) { "timeout after $effectiveTimeout seconds; $Containment process tree verified dead, streams closed, containment cleaned" } else { "timeout after $effectiveTimeout seconds; termination verification failed" })
                    process_tree_live = (-not $terminationVerified); output_activity = $outputActivity; streams_closed = $streamsClosed
                    stdout_stream_closed = $streamState.stdout_closed; stderr_stream_closed = $streamState.stderr_closed; cleanup_verified = $cleanupVerified
                }
            }
            if (-not $terminationVerified) {
                return [pscustomobject]@{ runtime_outcome = 'abandoned'; termination_verified = $false; containment = 'unknown'; failure_reason = "$Containment termination, stream closure, or cleanup verification failed"; process_tree_live = $true; output_activity = $outputActivity; streams_closed = $streamsClosed; stdout_stream_closed = $streamState.stdout_closed; stderr_stream_closed = $streamState.stderr_closed; cleanup_verified = $cleanupVerified; exit_code = $exitCode }
            }
            $outcome = if ($exitCode -eq 0) { 'completed' } else { 'terminated' }
            return [pscustomobject]@{
                runtime_outcome = $outcome; termination_verified = $true; containment = 'verified'
                failure_reason = $(if ($exitCode -eq 0) { $null } else { "reviewer-process-exit-code:$exitCode" })
                process_tree_live = $false; output_activity = $outputActivity; streams_closed = $streamsClosed
                stdout_stream_closed = $streamState.stdout_closed; stderr_stream_closed = $streamState.stderr_closed; cleanup_verified = $cleanupVerified; exit_code = $exitCode
            }
        }
        catch {
            $why = $_.Exception.Message
            if (-not $started) { return [pscustomobject]@{ runtime_outcome = 'launch-failed'; termination_verified = $true; containment = 'unknown'; failure_reason = ("$Platform-runtime-launch-failed:" + $why); process_tree_live = $false; output_activity = $false } }
            try { $process.StandardInput.Close() } catch { $null = $_ }
            if ($null -ne $descriptor) { try { & $StopContainment $descriptor 0 } catch { $null = $_ } }
            try { if (-not $process.HasExited) { [void]$process.WaitForExit(5000) } } catch { $null = $_ }
            $streamState = & $waitOutputDrainsCommand -StdoutDrain $stdoutDrain -StderrDrain $stderrDrain -TimeoutMilliseconds 5000
            $streamsClosed = $streamState.all_closed
            $containmentEmpty = if ($null -ne $descriptor) { try { [bool](& $WaitContainmentEmpty $descriptor 5000) } catch { $false } } else { $false }
            $rootDead = try { & $testDeadCommand -ProcessId $process.Id } catch { $false }
            $cleanupVerified = if ($null -ne $descriptor) { try { [bool](& $CleanupContainment $descriptor) } catch { $false } } else { $false }
            $verified = $streamsClosed -and $containmentEmpty -and $rootDead -and $cleanupVerified
            return [pscustomobject]@{ runtime_outcome = 'abandoned'; termination_verified = $verified; containment = $(if ($containmentVerified) { 'verified' } else { 'unknown' }); failure_reason = ("$Platform-runtime-failed:" + $why); process_tree_live = (-not $verified); output_activity = $false; streams_closed = $streamsClosed; stdout_stream_closed = $streamState.stdout_closed; stderr_stream_closed = $streamState.stderr_closed; cleanup_verified = $cleanupVerified }
        }
        finally {
            if ($null -ne $descriptor) { try { & $CleanupContainment $descriptor | Out-Null } catch { $null = $_ } }
            if (-not [string]::IsNullOrWhiteSpace($readyPath)) { try { Remove-Item -LiteralPath $readyPath -Force -ErrorAction SilentlyContinue } catch { $null = $_ } }
            if ($null -ne $process) { try { $process.Dispose() } catch { $null = $_ } }
        }
    }.GetNewClosure()

    $recover = { param($receipt) & $RecoverContainment $receipt }.GetNewClosure()

    return [pscustomobject]@{ id = $RuntimeId; platform = $Platform; containment = $Containment; preflight = $preflight; invoke = $invoke; recover = $recover }
}