workflows/default/systems/runtime/modules/task-reset.ps1

<#
.SYNOPSIS
Task reset utilities for autonomous task management

.DESCRIPTION
Provides functions for resetting in-progress and skipped tasks back to todo status
#>


function Reset-InProgressTasks {
    <#
    .SYNOPSIS
    Reset all in-progress tasks to todo status
    
    .PARAMETER TasksBaseDir
    Base directory containing task subdirectories (todo, in-progress, done)
    
    .OUTPUTS
    Array of hashtables with reset task information
    #>

    param(
        [Parameter(Mandatory = $true)]
        [string]$TasksBaseDir
    )
    
    $resetTasks = @()
    $inProgressDir = Join-Path $TasksBaseDir "in-progress"
    
    if (-not (Test-Path $inProgressDir)) {
        return $resetTasks
    }
    
    $inProgressTasks = @(Get-ChildItem -Path $inProgressDir -Filter "*.json" -File -ErrorAction SilentlyContinue)
    
    if ($inProgressTasks.Count -eq 0) {
        return $resetTasks
    }
    
    foreach ($taskFile in $inProgressTasks) {
        try {
            # Re-verify file exists (may have been moved by concurrent process)
            if (-not (Test-Path $taskFile.FullName)) { continue }

            $taskContent = Get-Content -Path $taskFile.FullName -Raw | ConvertFrom-Json
            $taskId = $taskContent.id
            $taskName = $taskContent.name

            # Check if this task was already completed — if so, just delete the orphan
            $doneFile = Join-Path $TasksBaseDir "done" $taskFile.Name
            if (Test-Path $doneFile) {
                Remove-Item -Path $taskFile.FullName -Force -ErrorAction SilentlyContinue
                continue
            }

            # If task has analysis data, return to analysed; otherwise to todo
            $hasAnalysis = $taskContent.analysis -and $taskContent.analysis.PSObject.Properties.Count -gt 0
            if ($hasAnalysis) {
                $targetDir = Join-Path $TasksBaseDir "analysed"
                $targetStatus = "analysed"
            } else {
                $targetDir = Join-Path $TasksBaseDir "todo"
                $targetStatus = "todo"
            }

            # Ensure target directory exists
            if (-not (Test-Path $targetDir)) {
                New-Item -ItemType Directory -Path $targetDir -Force | Out-Null
            }

            $targetPath = Join-Path $targetDir $taskFile.Name

            # Update status
            $taskContent.status = $targetStatus
            $taskContent.started_at = $null
            $taskContent.updated_at = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")

            # Write to target directory
            $taskContent | ConvertTo-Json -Depth 10 | Set-Content -Path $targetPath -Force

            # Remove from in-progress (ignore if already gone — concurrent process handled it)
            Remove-Item -Path $taskFile.FullName -Force -ErrorAction SilentlyContinue
            
            $resetTasks += @{
                id = $taskId
                name = $taskName
                file = $taskFile.Name
            }
        } catch {
            Write-BotLog -Level Warn -Message "Error processing task: $($taskFile.Name)" -Exception $_
        }
    }
    
    return $resetTasks
}

function Reset-SkippedTasks {
    <#
    .SYNOPSIS
    Reset all skipped tasks to todo status
    
    .PARAMETER TasksBaseDir
    Base directory containing task subdirectories (todo, in-progress, skipped, done)
    
    .OUTPUTS
    Array of hashtables with reset task information
    #>

    param(
        [Parameter(Mandatory = $true)]
        [string]$TasksBaseDir
    )
    
    $resetTasks = @()
    $skippedDir = Join-Path $TasksBaseDir "skipped"
    
    if (-not (Test-Path $skippedDir)) {
        return $resetTasks
    }
    
    $skippedTasks = @(Get-ChildItem -Path $skippedDir -Filter "*.json" -File -ErrorAction SilentlyContinue)
    
    if ($skippedTasks.Count -eq 0) {
        return $resetTasks
    }
    
    foreach ($taskFile in $skippedTasks) {
        try {
            # Re-verify file exists (may have been moved by concurrent process)
            if (-not (Test-Path $taskFile.FullName)) { continue }

            $taskContent = Get-Content -Path $taskFile.FullName -Raw | ConvertFrom-Json
            $taskId = $taskContent.id
            $taskName = $taskContent.name

            # Guard against infinite skip loops — leave persistently-failing tasks for manual review
            $skipCount = ($taskContent.skip_history | Measure-Object).Count
            if ($skipCount -ge 3) {
                Write-BotLog -Level Warn -Message "Task '$taskName' skipped $skipCount times - leaving in skipped for manual review"
                continue
            }

            # Check if this task was already completed — if so, just delete the orphan
            $doneFile = Join-Path $TasksBaseDir "done" $taskFile.Name
            if (Test-Path $doneFile) {
                Remove-Item -Path $taskFile.FullName -Force -ErrorAction SilentlyContinue
                continue
            }

            # Move to todo directory
            $todoDir = Join-Path $TasksBaseDir "todo"
            $todoPath = Join-Path $todoDir $taskFile.Name

            # Update status
            $taskContent.status = "todo"
            $taskContent.updated_at = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")

            # Preserve skip_history as audit trail
            # (don't clear it - this is intentional to maintain history for debugging)

            # Write to todo directory
            $taskContent | ConvertTo-Json -Depth 10 | Set-Content -Path $todoPath -Force

            # Remove from skipped (ignore if already gone — concurrent process handled it)
            Remove-Item -Path $taskFile.FullName -Force -ErrorAction SilentlyContinue
            
            $resetTasks += @{
                id = $taskId
                name = $taskName
                file = $taskFile.Name
                skip_count = ($taskContent.skip_history | Measure-Object).Count
            }
        } catch {
            Write-BotLog -Level Warn -Message "Error processing skipped task: $($taskFile.Name)" -Exception $_
        }
    }

    return $resetTasks
}

function Reset-AnalysingTasks {
    <#
    .SYNOPSIS
    Reset orphaned analysing tasks back to todo status

    .DESCRIPTION
    Cross-references tasks in analysing/ against live processes in the process registry.
    A task is considered orphaned if no running/starting process owns it AND its updated_at
    is older than 5 minutes (safety buffer to avoid racing with freshly launched processes).

    .PARAMETER TasksBaseDir
    Base directory containing task subdirectories (todo, analysing, etc.)

    .PARAMETER ProcessesDir
    Directory containing process registry JSON files

    .OUTPUTS
    Array of hashtables with recovered task information
    #>

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

        [Parameter(Mandatory = $true)]
        [string]$ProcessesDir
    )

    $resetTasks = @()
    $analysingDir = Join-Path $TasksBaseDir "analysing"

    if (-not (Test-Path $analysingDir)) {
        return $resetTasks
    }

    $analysingTasks = @(Get-ChildItem -Path $analysingDir -Filter "*.json" -File -ErrorAction SilentlyContinue)

    if ($analysingTasks.Count -eq 0) {
        return $resetTasks
    }

    # Build set of task IDs owned by LIVE processes
    $liveTaskIds = [System.Collections.Generic.HashSet[string]]::new()

    if (Test-Path $ProcessesDir) {
        $processFiles = Get-ChildItem -Path $ProcessesDir -Filter "*.json" -File -ErrorAction SilentlyContinue

        foreach ($procFile in $processFiles) {
            try {
                $proc = Get-Content -Path $procFile.FullName -Raw | ConvertFrom-Json

                # Only consider running or starting processes
                if ($proc.status -notin @('running', 'starting')) { continue }

                # Verify the PID is actually alive
                $isAlive = $false
                if ($proc.pid) {
                    try {
                        Get-Process -Id $proc.pid -ErrorAction Stop | Out-Null
                        $isAlive = $true
                    } catch {
                        # PID not found - process is dead
                    }
                }

                if ($isAlive -and $proc.task_id) {
                    [void]$liveTaskIds.Add($proc.task_id)
                }
            } catch {
                # Skip malformed process files
            }
        }
    }

    $now = (Get-Date).ToUniversalTime()
    $stalenessThreshold = $now.AddMinutes(-5)

    foreach ($taskFile in $analysingTasks) {
        try {
            # Re-verify file exists (may have been moved by concurrent process)
            if (-not (Test-Path $taskFile.FullName)) { continue }

            $taskContent = Get-Content -Path $taskFile.FullName -Raw | ConvertFrom-Json
            $taskId = $taskContent.id
            $taskName = $taskContent.name

            # Skip if a live process owns this task
            if ($liveTaskIds.Contains($taskId)) { continue }

            # Safety buffer: skip if updated_at is less than 5 minutes ago
            if ($taskContent.updated_at) {
                # ConvertFrom-Json auto-parses ISO dates to DateTime; avoid double-parsing
                # which mangles month/day order across cultures
                $updatedAt = if ($taskContent.updated_at -is [datetime]) {
                    $taskContent.updated_at.ToUniversalTime()
                } else {
                    [DateTimeOffset]::Parse($taskContent.updated_at).UtcDateTime
                }
                if ($updatedAt -gt $stalenessThreshold) { continue }
            }

            # Check if this task was already completed — if so, just delete the orphan
            $doneFile = Join-Path $TasksBaseDir "done" $taskFile.Name
            if (Test-Path $doneFile) {
                Remove-Item -Path $taskFile.FullName -Force -ErrorAction SilentlyContinue
                continue
            }

            # This task is orphaned and not yet done - recover it to todo
            $todoDir = Join-Path $TasksBaseDir "todo"
            if (-not (Test-Path $todoDir)) {
                New-Item -ItemType Directory -Path $todoDir -Force | Out-Null
            }
            $todoPath = Join-Path $todoDir $taskFile.Name

            # Update status and timestamps
            $taskContent.status = "todo"
            $taskContent.updated_at = $now.ToString("yyyy-MM-ddTHH:mm:ssZ")

            # Clear analysis_started_at
            if ($taskContent.PSObject.Properties['analysis_started_at']) {
                $taskContent.analysis_started_at = $null
            }

            # Close any open analysis session (no ended_at)
            if ($taskContent.analysis_sessions) {
                foreach ($session in $taskContent.analysis_sessions) {
                    if ($session.PSObject.Properties['ended_at'] -and -not $session.ended_at) {
                        $session.ended_at = $now.ToString("yyyy-MM-ddTHH:mm:ssZ")
                    } elseif (-not $session.PSObject.Properties['ended_at']) {
                        $session | Add-Member -NotePropertyName 'ended_at' -NotePropertyValue $now.ToString("yyyy-MM-ddTHH:mm:ssZ")
                    }
                }
            }

            # Preserve analysis_sessions, questions_resolved, skip_history for audit

            # Write to todo directory
            $taskContent | ConvertTo-Json -Depth 10 | Set-Content -Path $todoPath -Force

            # Remove from analysing (ignore if already gone — concurrent process handled it)
            Remove-Item -Path $taskFile.FullName -Force -ErrorAction SilentlyContinue

            $resetTasks += @{
                id   = $taskId
                name = $taskName
                file = $taskFile.Name
            }
        } catch {
            Write-BotLog -Level Warn -Message "Error processing analysing task: $($taskFile.Name)" -Exception $_
        }
    }

    return $resetTasks
}