Skills/long-running-job-monitor/scripts/Start-JobMonitor.ps1

<#
.SYNOPSIS
    Samples structured target liveness and progress on a fixed cadence.
.DESCRIPTION
    Launch this DETACHED (Start-Process, or an async terminal) so its internal
    Start-Sleep runs in a background process, never in the agent's own
    foreground command. The agent reads the .status file on demand or when
    notified. Pair it with an instrumented job log (see SKILL.md technique 1).
    -GetTargetState must be a read-only probe returning exactly one object with
    Summary, Liveness, and ProgressToken properties. Only ProgressToken changes
    reset the last-progress timestamp.
.EXAMPLE
    $probeText = '[pscustomobject]@{ Summary = "ready"; Liveness = $true; ProgressToken = 1 }'
    $encodedProbe = [Convert]::ToBase64String(
        [Text.Encoding]::Unicode.GetBytes($probeText)
    )
    $jobName = 'deploy-vm01-{0}' -f [guid]::NewGuid().ToString('N')
    $monitorScript = Join-Path $HOME '.copilot/skills/long-running-job-monitor/scripts/Start-JobMonitor.ps1'
    $escapedScript = $monitorScript.Replace("'", "''")
    $launcherPayload = "& '$escapedScript' -JobName '$jobName' -DurationMinutes 30 -GetTargetStateBase64 '$encodedProbe'"
    $encodedLauncher = [Convert]::ToBase64String(
        [Text.Encoding]::Unicode.GetBytes($launcherPayload)
    )
    $launcherPath = Join-Path $HOME '.copilot/skills/long-running-job-monitor/scripts/Start-DetachedPowerShell.ps1'
    & $launcherPath -EncodedCommand $encodedLauncher
#>

[CmdletBinding(DefaultParameterSetName = 'ScriptBlock')]
param(
    [Parameter(Mandatory)]
    [ValidateNotNullOrEmpty()]
    [string] $JobName,

    # Direct invocation: read-only probe returning one structured object.
    [Parameter(Mandatory, ParameterSetName = 'ScriptBlock')]
    [scriptblock] $GetTargetState,

    # Detached native-process invocation: UTF-16LE Base64 probe body. Do not
    # include surrounding script-block braces.
    [Parameter(Mandatory, ParameterSetName = 'EncodedProbe')]
    [ValidateNotNullOrEmpty()]
    [string] $GetTargetStateBase64,

    [ValidateNotNullOrEmpty()]
    [string] $StatusPath = (Join-Path $env:TEMP "job-$JobName.status"),

    [ValidateNotNullOrEmpty()]
    [string] $LogPath = (Join-Path $env:TEMP "job-$JobName.log"),

    # 300 s = the ~5-minute cadence the monitoring pattern prescribes. Clamp:
    # >= 5 s so it cannot hot-loop, <= 3600 s so a sample always lands hourly.
    [ValidateRange(5, 3600)]
    [int] $IntervalSeconds = 300,

    # How long to keep sampling; size to the job's expected duration.
    [ValidateRange(1, 100000)]
    [int] $DurationMinutes = 60,

    # Duration controls normal runs. MaxSamples enables bounded smoke tests and
    # intentionally short monitoring windows without changing the cadence.
    [ValidateRange(1, [int]::MaxValue)]
    [int] $MaxSamples = [int]::MaxValue
)

$ErrorActionPreference = 'Stop'

if ($PSCmdlet.ParameterSetName -eq 'EncodedProbe') {
    try {
        $probeText = [Text.Encoding]::Unicode.GetString(
            [Convert]::FromBase64String($GetTargetStateBase64)
        )
    }
    catch {
        throw "GetTargetStateBase64 is not valid UTF-16LE Base64: $($_.Exception.Message)"
    }

    $trimmedProbeText = $probeText.Trim()
    if ($trimmedProbeText.StartsWith('{') -and $trimmedProbeText.EndsWith('}')) {
        throw 'GetTargetStateBase64 must encode the probe body without surrounding script-block braces.'
    }

    try {
        $GetTargetState = [scriptblock]::Create($probeText)
    }
    catch {
        throw "GetTargetStateBase64 does not contain a valid PowerShell probe body: $($_.Exception.Message)"
    }
}

function Get-HeartbeatAge {
    # Seconds since the freshest "[HH:mm:ss] ..." line in the job log, or $null.
    param([string] $Path)

    if (-not (Test-Path -LiteralPath $Path)) { return $null }

    $line = Get-Content -LiteralPath $Path -Tail 40 -ErrorAction SilentlyContinue |
        Where-Object { $_ -match '^\[(\d{2}:\d{2}:\d{2})\]' } |
        Select-Object -Last 1
    if (-not $line) { return $null }

    $stamp = [regex]::Match($line, '^\[(\d{2}:\d{2}:\d{2})\]').Groups[1].Value
    try {
        $t = [datetime]::ParseExact($stamp, 'HH:mm:ss', [cultureinfo]::InvariantCulture)
        # Crossed midnight: a future stamp belongs to yesterday.
        if ($t -gt (Get-Date)) { $t = $t.AddDays(-1) }
        return [int]((Get-Date) - $t).TotalSeconds
    }
    catch {
        return $null
    }
}

$start    = Get-Date
$deadline = $start.AddMinutes($DurationMinutes)
$lastProgressAt = $start
$previousProgressToken = $null
$hasProgressSample = $false
$sampleCount = 0

"[{0}] MONITOR-START {1} (interval={2}s duration={3}m)" -f (Get-Date -Format 'HH:mm:ss'), $JobName, $IntervalSeconds, $DurationMinutes |
    Add-Content -LiteralPath $StatusPath

while ((Get-Date) -lt $deadline -and $sampleCount -lt $MaxSamples) {
    $elapsedMin = [int]((Get-Date) - $start).TotalMinutes

    try {
        $probeResults = @(& $GetTargetState)
        if ($probeResults.Count -ne 1) {
            throw "GetTargetState returned $($probeResults.Count) objects; expected exactly one."
        }

        $probe = $probeResults[0]
        foreach ($propertyName in 'Summary', 'Liveness', 'ProgressToken') {
            if (-not $probe.PSObject.Properties[$propertyName]) {
                throw "GetTargetState result is missing '$propertyName'."
            }
        }

        $summary = [string]$probe.Summary
        $liveness = [string]$probe.Liveness
        $progressToken = $probe.ProgressToken
        $progressTokenKey = if ($null -eq $progressToken) {
            '<null>'
        }
        else {
            ConvertTo-Json -InputObject $progressToken -Compress -Depth 10
        }

        if (-not $hasProgressSample -or $progressTokenKey -ne $previousProgressToken) {
            $lastProgressAt = Get-Date
            $previousProgressToken = $progressTokenKey
            $hasProgressSample = $true
        }
    }
    catch {
        $summary = "probe-error: $($_.Exception.Message)"
        $liveness = 'false'
        $progressTokenKey = if ($hasProgressSample) { $previousProgressToken } else { '<none>' }
    }

    $ageSec = Get-HeartbeatAge -Path $LogPath
    $hb = if ($null -eq $ageSec) { 'none' } else { "${ageSec}s ago" }
    $progressAgeSec = [int]((Get-Date) - $lastProgressAt).TotalSeconds

    "[{0}] elapsed={1}m | last-progress={2}s ago | progress={3} | last-heartbeat={4} | liveness={5} | target={6}" -f (Get-Date -Format 'HH:mm:ss'), $elapsedMin, $progressAgeSec, $progressTokenKey, $hb, $liveness, $summary |
        Add-Content -LiteralPath $StatusPath

    $sampleCount++
    if ($sampleCount -ge $MaxSamples -or (Get-Date).AddSeconds($IntervalSeconds) -ge $deadline) {
        break
    }

    # Legitimate Start-Sleep: this runs in a BACKGROUNDED process, never in the
    # agent's own foreground command. The agent must not self-sleep to wait.
    Start-Sleep -Seconds $IntervalSeconds
}

"[{0}] MONITOR-END {1}" -f (Get-Date -Format 'HH:mm:ss'), $JobName |
    Add-Content -LiteralPath $StatusPath