Payload/scripts/kaden/harness/harness-record-task.ps1

#Requires -Version 5.1
<#
.SYNOPSIS
    Journal background TaskStarted / Heartbeat / TimedOut / Recovered / Abandoned events.

.DESCRIPTION
    Escape hatch for long-running work (mutation:diff, nested Task subagents) so stalled
    jobs leave an accountable ledger trail instead of silent open turns. Does not kill
    processes -- agents/operators still decide recover vs abandon.

.EXAMPLE
    pwsh -File scripts/kaden/harness-invoke.ps1 harness-record-task.ps1 -Feature saved-search-queries -Slice mo-save-list -Action start -TaskName mutation:diff -TtlMinutes 30 -ExpectedOutputPath reports/stryker-incremental.json

.EXAMPLE
    pwsh -File scripts/kaden/harness-invoke.ps1 harness-record-task.ps1 -Feature saved-search-queries -Slice mo-save-list -Action timeout -TaskId act_ABC -Summary "mutation:diff exceeded TTL"
#>

[CmdletBinding()]
param(
    [Parameter(Mandatory = $true)]
    [string]$Feature,

    [Parameter(Mandatory = $true)]
    [string]$Slice,

    [Parameter(Mandatory = $true)]
    [ValidateSet('start', 'heartbeat', 'timeout', 'recover', 'abandon')]
    [string]$Action,

    [string]$TaskName = '',

    [string]$TaskId = '',

    [int]$TtlMinutes = 0,

    [string]$ExpectedOutputPath = '',

    [string]$Summary = '',

    [ValidateSet('cursor', 'copilot', 'other')]
    [string]$ModelProvider = 'copilot',

    [string]$ModelSlug = 'unknown',

    [string]$Agent = 'atdd-developer',

    [string]$ParentAgent = '',

    [string]$FeatureRoot = '',

    [string]$RepoRoot = '',

    [switch]$AllowArbitraryFeatureRoot
)

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

Import-Module (Join-Path $PSScriptRoot 'lib\HarnessContract.psm1') -Force
Import-Module (Join-Path $PSScriptRoot 'lib\HarnessJournal.psm1') -Force
Import-Module (Join-Path $PSScriptRoot 'lib\HarnessGateProjection.psm1') -Force
Test-HarnessContractModuleReady

if ([string]::IsNullOrWhiteSpace($RepoRoot)) {
    $RepoRoot = Get-HarnessRepoRoot -StartPath $PSScriptRoot
}
$featureRootPath = Get-HarnessFeatureRoot -RepoRoot $RepoRoot -Feature $Feature `
    -FeatureRootOverride $FeatureRoot -AllowArbitraryFeatureRoot:$AllowArbitraryFeatureRoot

$kindMap = @{
    start     = 'TaskStarted'
    heartbeat = 'TaskHeartbeat'
    timeout   = 'TaskTimedOut'
    recover   = 'TaskRecovered'
    abandon   = 'TaskAbandoned'
}
$kind = $kindMap[$Action]

if ($Action -eq 'start') {
    if ([string]::IsNullOrWhiteSpace($TaskName)) {
        throw '-TaskName is required for -Action start'
    }
    if (Test-HarnessMutationTaskStartBlocked -FeatureRoot $featureRootPath -SliceId $Slice -TaskName $TaskName) {
        throw "MUTATE is already skipped for slice $Slice; do not start a $TaskName task."
    }
    if ($TtlMinutes -le 0) {
        throw '-TtlMinutes must be > 0 for -Action start (escape hatch requires a deadline)'
    }
    if ([string]::IsNullOrWhiteSpace($TaskId)) {
        $TaskId = New-HarnessJournalActivityId
    }
    if ([string]::IsNullOrWhiteSpace($Summary)) {
        $Summary = "task start $TaskName ttl=${TtlMinutes}m"
    }
}
else {
    if ([string]::IsNullOrWhiteSpace($TaskId)) {
        throw "-TaskId is required for -Action $Action (use the activity id from TaskStarted)"
    }
    if ([string]::IsNullOrWhiteSpace($TaskName)) {
        $TaskName = 'background-task'
    }
    if ([string]::IsNullOrWhiteSpace($Summary)) {
        $Summary = "task $Action $TaskName"
    }
}

$resolvedSlug = Get-HarnessResolvedModelSlug -ModelSlug $ModelSlug
$actor = New-HarnessJournalActor -Agent $Agent -ParentAgent $(if ($ParentAgent) { $ParentAgent } else { $null }) `
    -Tool 'harness-script' -Provider $ModelProvider -Slug $resolvedSlug -Role 'primary'
$activity = New-HarnessJournalActivity -Id $TaskId -Type 'other' -Name $TaskName -Role 'instant'
$outcome = switch ($Action) {
    'timeout' { 'FAIL' }
    'abandon' { 'BLOCKED' }
    'recover' { 'PASS' }
    default { $null }
}

$deadline = $null
if ($Action -eq 'start' -and $TtlMinutes -gt 0) {
    $deadline = ([DateTime]::UtcNow.AddMinutes($TtlMinutes)).ToString('yyyy-MM-ddTHH:mm:ssZ')
}

$evt = Add-HarnessJournalEvent -FeatureRoot $featureRootPath -Feature $Feature -Slice $Slice `
    -Kind $kind -Activity $activity -Actor $actor -Outcome $outcome `
    -Summary $Summary `
    -Payload ([pscustomobject]@{
        task_name             = $TaskName
        ttl_minutes           = $(if ($TtlMinutes -gt 0) { $TtlMinutes } else { $null })
        deadline_utc          = $deadline
        expected_output_path  = $(if ($ExpectedOutputPath) { $ExpectedOutputPath } else { $null })
        action                = $Action
    }) `
    -Refs ([pscustomobject]@{
        amends_event_id = $null
        pair_event_id   = $null
        artifact        = $(if ($ExpectedOutputPath) { $ExpectedOutputPath } else { $null })
        command         = '.\scripts\kaden\harness-record-task.ps1'
    })

Update-HarnessGateProjectionFromJournal -FeatureRoot $featureRootPath -Feature $Feature -RepoRoot $RepoRoot -Write | Out-Null

[pscustomobject]@{
    operation = 'record-task'
    feature   = $Feature
    slice     = $Slice
    action    = $Action
    kind      = $kind
    task_id   = $TaskId
    task_name = $TaskName
    event_id  = $evt.event_id
    deadline  = $deadline
} | ConvertTo-Json -Depth 5