Private/Executor.ps1

using namespace System.Collections
using namespace System.Collections.Generic
using namespace System.Collections.Specialized
using namespace System.Management.Automation

# Task execution. The flow of RunTask mirrors go-task's Executor.RunTask.
#
# $ctx is the run context shared by every branch (see TaskEngine.NewContext). A "branch" is
# one thread of execution: the root, or one of the tasks started in parallel (deps and
# -Parallel). Each branch has its own runspace with this module imported.

[NoRunspaceAffinity()]
class TaskExecutor {
    static [int] $MaximumTaskCall = 1000
    static [int] $MaximumDepth = 100

    # Nested task calls continue on a fresh thread every few levels. An exception that
    # travels up through many catch/rethrow layers needs a lot of stack on each layer, so an
    # unbounded chain on one thread could overflow the stack instead of failing cleanly.
    static [int] $HopInterval = 8

    static [object] NewCall([string] $task, [OrderedDictionary] $vars, [bool] $silent, [bool] $indirect, [int] $depth) {
        $callVars = [OrderedDictionary]::new()
        if ($null -ne $vars) { foreach ($key in $vars.get_Keys()) { $callVars[[string]$key] = $vars[$key] } }
        return [pscustomobject]@{ Task = $task; Vars = $callVars; Silent = $silent; Indirect = $indirect; Depth = $depth }
    }

    static [string] TaskName([object] $task) {
        if ([string]::IsNullOrEmpty($task.Label)) { return $task.Task }
        return $task.Label
    }

    hidden static [Exception] CommandError([string] $message, [int] $exitStatus) {
        $exception = [TaskError]::Create($message, [TaskError]::TaskRun)
        $exception.Data['TaskExitStatus'] = $exitStatus
        return $exception
    }

    # ---- task lookup -------------------------------------------------------------------------

    hidden static [int] Distance([string] $a, [string] $b) {
        $previous = [int[]]::new($b.Length + 1)
        for ($j = 0; $j -le $b.Length; $j++) { $previous[$j] = $j }
        for ($i = 1; $i -le $a.Length; $i++) {
            $current = [int[]]::new($b.Length + 1)
            $current[0] = $i
            for ($j = 1; $j -le $b.Length; $j++) {
                $cost = if ($a[$i - 1] -ceq $b[$j - 1]) { 0 } else { 1 }
                $current[$j] = [Math]::Min([Math]::Min($current[$j - 1] + 1, $previous[$j] + 1), $previous[$j - 1] + $cost)
            }
            $previous = $current
        }
        return $previous[$b.Length]
    }

    hidden static [string] Suggest([hashtable] $ctx, [string] $name) {
        $best = ''
        $bestDistance = [int]::MaxValue
        foreach ($task in $ctx.Taskfile.Tasks.get_Values()) {
            if ($task.Internal) { continue }
            foreach ($candidate in @($task.Task) + @($task.Aliases)) {
                $distance = [TaskExecutor]::Distance($name, $candidate)
                if ($distance -lt $bestDistance) { $bestDistance = $distance; $best = $candidate }
            }
        }
        if ($bestDistance -le [Math]::Max(2, [int]($name.Length / 2))) { return $best }
        return ''
    }

    # Captured wildcards when $name matches the task name (or an alias) pattern, else $null.
    hidden static [List[object]] WildcardMatch([object] $task, [string] $name) {
        foreach ($pattern in @($task.Task) + @($task.Aliases)) {
            $regex = '^' + [regex]::Escape($pattern).Replace('\*', '(.*)') + '$'
            $match = [regex]::Match($name, $regex)
            if (-not $match.Success) { continue }
            $wildcards = [List[object]]::new()
            for ($i = 1; $i -lt $match.Groups.Count; $i++) { $wildcards.Add($match.Groups[$i].Value) }
            return $wildcards
        }
        return $null
    }

    # Finds the task for a call: exact name, then wildcard patterns and aliases.
    static [object] FindTask([hashtable] $ctx, [object] $call) {
        $tasks = $ctx.Taskfile.Tasks
        if ($tasks.Contains($call.Task)) { return $tasks[$call.Task] }

        $found = [List[object]]::new()
        $wildcards = $null
        foreach ($task in $tasks.get_Values()) {
            $captured = [TaskExecutor]::WildcardMatch($task, $call.Task)
            if ($null -ne $captured) { $found.Add($task); $wildcards = $captured }
        }
        if ($found.Count -eq 1) {
            $call.Vars['MATCH'] = [TaskfileReader]::NewVar('static', $wildcards)
            return $found[0]
        }
        if ($found.Count -gt 1) {
            $names = [List[string]]::new()
            foreach ($task in $found) { $names.Add($task.Task) }
            throw [TaskError]::Create("task: Found multiple tasks ($([string]::Join(', ', $names))) that match `"$($call.Task)`"", [TaskError]::TaskNameConflict)
        }
        $message = "task: Task `"$($call.Task)`" does not exist"
        $suggestion = [TaskExecutor]::Suggest($ctx, $call.Task)
        if ($suggestion.Length -gt 0) { $message += ". Did you mean `"$suggestion`"?" }
        throw [TaskError]::Create($message, [TaskError]::TaskNotFound)
    }

    static [bool] PlatformMatches([object] $platforms) {
        if ($null -eq $platforms -or $platforms.Count -eq 0) { return $true }
        $os = [TplFn]::OS()
        $arch = [TplFn]::Arch()
        foreach ($platform in $platforms) {
            if (($platform.OS -eq '' -or $platform.OS -ceq $os) -and ($platform.Arch -eq '' -or $platform.Arch -ceq $arch)) { return $true }
        }
        return $false
    }

    # ---- concurrency ---------------------------------------------------------------------------

    hidden static [void] Acquire([hashtable] $ctx) {
        if ($null -eq $ctx.Semaphore) { return }
        while (-not $ctx.Semaphore.Wait(50)) { $ctx.Cancel.Token.ThrowIfCancellationRequested() }
    }

    hidden static [void] Release([hashtable] $ctx) {
        if ($null -ne $ctx.Semaphore) { [void]$ctx.Semaphore.Release() }
    }

    static [hashtable] RunBranch([hashtable] $ctx, [object] $call) {
        try {
            [TaskExecutor]::RunTask($ctx, $call)
            return @{ Ok = $true }
        }
        catch {
            if ([TaskScript]::IsStop($_.Exception)) { throw }
            $exception = [TaskError]::Unwrap($_.Exception)
            return @{
                Ok         = $false
                Message    = $exception.Message
                Code       = [TaskError]::CodeOf($exception)
                ExitStatus = $exception.Data['TaskExitStatus']
            }
        }
    }

    hidden static [hashtable] StartBranch([hashtable] $ctx, [object] $call) {
        $runspace = $null
        if (-not $ctx.IdleRunspaces.TryTake([ref] $runspace)) {
            $initialState = [initialsessionstate]::CreateDefault2()
            $initialState.ImportPSModule([string[]]@([string]$ctx.ModuleFile))
            $runspace = [runspacefactory]::CreateRunspace($ctx.Host, $initialState)
            $runspace.Open()
        }
        $shell = [powershell]::Create()
        $shell.Runspace = $runspace
        $entry = 'param($ctx, $call) & (Get-Module PS-TaskFile) { param($c, $k) [TaskExecutor]::RunBranch($c, $k) } $ctx $call'
        [void]$shell.AddScript($entry).AddArgument($ctx).AddArgument($call)
        return @{ Shell = $shell; Runspace = $runspace; Handle = $shell.BeginInvoke(); Done = $false }
    }

    # Runs the calls concurrently, each in its own branch. Throws the first failure.
    static [void] RunParallel([hashtable] $ctx, [List[object]] $calls, [bool] $failfast) {
        if ($calls.Count -eq 0) { return }
        if ($calls.Count -eq 1) {
            $only = $calls[0]
            if ($only.Depth -eq 0 -or ($only.Depth % [TaskExecutor]::HopInterval) -ne 0) {
                [TaskExecutor]::RunTask($ctx, $only)
                return
            }
        }

        $branches = [List[hashtable]]::new()
        $failure = $null
        try {
            foreach ($call in $calls) { $branches.Add([TaskExecutor]::StartBranch($ctx, $call)) }
            $pending = $branches.Count
            while ($pending -gt 0) {
                $progressed = $false
                foreach ($branch in $branches) {
                    if ($branch.Done -or -not $branch.Handle.IsCompleted) { continue }
                    $branch.Done = $true
                    $pending--
                    $progressed = $true
                    $result = $null
                    try {
                        $output = $branch.Shell.EndInvoke($branch.Handle)
                        if ($output.Count -gt 0) { $result = $output[0] }
                        if ($null -eq $result -and $branch.Shell.Streams.Error.Count -gt 0) {
                            $result = @{ Ok = $false; Message = $branch.Shell.Streams.Error[0].ToString(); Code = [TaskError]::Unknown; ExitStatus = $null }
                        }
                    }
                    catch {
                        if ($null -eq $failure) { $result = @{ Ok = $false; Message = 'task: cancelled'; Code = [TaskError]::TaskCancelled; ExitStatus = $null } }
                    }
                    if ($null -ne $result -and -not $result.Ok -and $null -eq $failure) {
                        $failure = $result
                        if ($failfast) {
                            foreach ($other in $branches) { if (-not $other.Done) { [void]$other.Shell.BeginStop($null, $null) } }
                        }
                    }
                }
                if (-not $progressed) {
                    $ctx.Cancel.Token.ThrowIfCancellationRequested()
                    Start-Sleep -Milliseconds 10
                }
            }
        }
        finally {
            foreach ($branch in $branches) {
                $reusable = $branch.Done
                if (-not $branch.Done) { try { $branch.Shell.Stop() } catch { } }
                $branch.Shell.Dispose()
                if ($reusable -and $branch.Runspace.RunspaceAvailability -eq [System.Management.Automation.Runspaces.RunspaceAvailability]::Available) {
                    $ctx.IdleRunspaces.Add($branch.Runspace)
                }
                else { $branch.Runspace.Dispose() }
            }
        }
        if ($null -ne $failure) {
            $exception = [TaskError]::Create([string]$failure.Message, [int]$failure.Code)
            if ($null -ne $failure.ExitStatus) { $exception.Data['TaskExitStatus'] = $failure.ExitStatus }
            throw $exception
        }
    }

    # ---- checks ----------------------------------------------------------------------------------

    hidden static [void] CheckRequiredVars([object] $task) {
        $missing = [List[string]]::new()
        foreach ($required in $task.Requires) {
            if ($task.Vars.Contains($required.Name)) { continue }
            $text = $required.Name
            if ($null -ne $required.Enum -and $required.Enum.Count -gt 0) { $text += " (allowed values: [$([string]::Join(' ', $required.Enum))])" }
            $missing.Add($text)
        }
        if ($missing.Count -gt 0) {
            $message = "task: Task `"$([TaskExecutor]::TaskName($task))`" cancelled because it is missing required variables: $([string]::Join(', ', $missing))"
            throw [TaskError]::Create($message, [TaskError]::TaskMissingRequiredVars)
        }
    }

    hidden static [void] CheckAllowedValues([object] $task) {
        $invalid = [List[string]]::new()
        foreach ($required in $task.Requires) {
            if ($null -eq $required.Enum -or $required.Enum.Count -eq 0 -or -not $task.Vars.Contains($required.Name)) { continue }
            $value = [TplFn]::Unwrap($task.Vars[$required.Name])
            if ($value -is [string] -and $required.Enum -cnotcontains $value) {
                $invalid.Add(" - $($required.Name) has an invalid value : '$value' (allowed values : [$([string]::Join(' ', $required.Enum))])")
            }
        }
        if ($invalid.Count -gt 0) {
            $message = "task: Task `"$([TaskExecutor]::TaskName($task))`" cancelled because it is missing required variables:`n$([string]::Join("`n", $invalid))`n"
            throw [TaskError]::Create($message, [TaskError]::TaskNotAllowedVars)
        }
    }

    hidden static [bool] IsSilent([hashtable] $ctx, [object] $task, [object] $call, [bool] $cmdSilent) {
        $taskSilent = if ($null -ne $task.Silent) { [bool]$task.Silent } else { [bool]$ctx.Taskfile.Silent }
        return $call.Silent -or $cmdSilent -or $taskSilent -or $ctx.Options.Silent
    }

    hidden static [bool] Condition([hashtable] $ctx, [object] $task, [string] $text) {
        return [TaskExecutor]::Condition($ctx, $task, $text, $null)
    }

    # Runs a condition script (status / precondition / if) with the environment and the
    # variables of the task; $extra are the variables of one command (loop variables).
    hidden static [bool] Condition([hashtable] $ctx, [object] $task, [string] $text, [IDictionary] $extra) {
        $variables = $task.ScriptVars
        if ($null -ne $extra -and $extra.get_Count() -gt 0) {
            $variables = [OrderedDictionary]::new()
            foreach ($key in $task.ScriptVars.get_Keys()) { $variables[[string]$key] = $task.ScriptVars[$key] }
            foreach ($key in $extra.get_Keys()) { $variables[[string]$key] = $extra[$key] }
        }
        [TaskEnv]::Enter($ctx.EnvGate, [TaskCompiler]::EnvOverrides($ctx, $task.Env), $ctx.Cancel.Token)
        try { return [TaskScript]::Test($text, $task.Dir, $variables) }
        finally { [TaskEnv]::Leave($ctx.EnvGate) }
    }

    static [string] Method([hashtable] $ctx, [object] $task) {
        if (-not [string]::IsNullOrEmpty($task.Method)) { return $task.Method }
        if (-not [string]::IsNullOrEmpty($ctx.Taskfile.Method)) { return $ctx.Taskfile.Method }
        return 'checksum'
    }

    # A task is up to date when its status commands succeed and/or its sources are unchanged.
    static [bool] IsUpToDate([hashtable] $ctx, [object] $task) {
        $statusSet = $task.Status.Count -gt 0
        $sourcesSet = $task.Sources.Count -gt 0
        $statusUpToDate = $true
        $sourcesUpToDate = $true
        if ($statusSet) {
            foreach ($status in $task.Status) {
                if ([TaskExecutor]::Condition($ctx, $task, $status)) {
                    [TaskLog]::Verbose($ctx, "task: status command $status exited zero")
                }
                else {
                    [TaskLog]::Verbose($ctx, "task: status command $status exited non-zero")
                    $statusUpToDate = $false
                    break
                }
            }
        }
        if ($sourcesSet) {
            $sourcesUpToDate = [TaskFingerprint]::IsUpToDate($task, [TaskExecutor]::Method($ctx, $task), [string]$ctx.TempDir, [bool]$ctx.Options.Dry)
        }
        if ($statusSet -and $sourcesSet) { return $statusUpToDate -and $sourcesUpToDate }
        if ($statusSet) { return $statusUpToDate }
        if ($sourcesSet) { return $sourcesUpToDate }
        return $false
    }

    hidden static [void] Prompt([hashtable] $ctx, [object] $task) {
        foreach ($prompt in $task.Prompt) {
            if ([string]::IsNullOrEmpty($prompt) -or $ctx.Options.Dry) { continue }
            if ($ctx.Options.AssumeYes) {
                [TaskLog]::Info($ctx, "$prompt [assuming yes]", 'Yellow')
                continue
            }
            if ([Console]::IsInputRedirected -or -not [Environment]::UserInteractive) {
                $message = "task: Task `"$($task.Task)`" cancelled because it has a prompt and the environment is not a terminal. Use -AssumeYes to run anyway."
                throw [TaskError]::Create($message, [TaskError]::TaskCancelled)
            }
            if ($ctx.Options.Color) { Write-Host "$prompt [y/N]: " -NoNewline -ForegroundColor Yellow } else { Write-Host "$prompt [y/N]: " -NoNewline }
            $answer = [string]$global:Host.UI.ReadLine()
            if ($answer.Trim().ToLowerInvariant() -notin @('y', 'yes')) {
                throw [TaskError]::Create("task: Task `"$($task.Task)`" cancelled by user", [TaskError]::TaskCancelled)
            }
        }
    }

    # ---- commands ----------------------------------------------------------------------------------

    hidden static [string] OutputStyle([hashtable] $ctx, [object] $task) {
        if ($task.Interactive) { return 'interleaved' }
        return [string]$ctx.Output.Name
    }

    # Runs the PowerShell of one command with the output style of the run.
    hidden static [object] RunScript([hashtable] $ctx, [object] $task, [object] $cmd, [object] $scope) {
        $style = [TaskExecutor]::OutputStyle($ctx, $task)
        $mode = 'host'
        $target = $null
        $buffer = $null
        if ($style -eq 'group') {
            $buffer = [List[string]]::new()
            $mode = 'lines'
            $target = @{ Buffer = $buffer; Sink = $null; UI = $null; Prefix = '' }
        }
        elseif ($style -eq 'prefixed') {
            $mode = 'lines'
            $target = @{ Buffer = $null; Sink = $ctx.Sink; UI = $global:Host.UI; Prefix = "[$($task.Prefix)] " }
        }
        elseif ($null -ne $ctx.Sink) {
            $mode = 'stream'
            $target = $ctx.Sink
        }

        # `set:` options are mapped onto their PowerShell counterparts.
        $options = @($ctx.Taskfile.Set) + @($task.Set) + @($cmd.Set)
        $strictNative = $options -contains 'errexit' -or $options -contains 'e' -or $options -contains 'pipefail'
        $trace = $options -contains 'xtrace' -or $options -contains 'x'
        $text = [string]$cmd.Cmd
        if ($options -contains 'nounset' -or $options -contains 'u') { $text = "Set-StrictMode -Version Latest`n$text" }
        if ($strictNative) { $scope.SessionState.PSVariable.Set('PSNativeCommandUseErrorActionPreference', $true) }
        if ($trace) { Set-PSDebug -Trace 1 }

        $result = $null
        [TaskEnv]::Enter($ctx.EnvGate, [TaskCompiler]::EnvOverrides($ctx, $task.Env), $ctx.Cancel.Token)
        try { $result = [TaskScript]::Invoke($text, $scope, $task.Dir, $mode, $target) }
        finally {
            [TaskEnv]::Leave($ctx.EnvGate)
            if ($trace) { Set-PSDebug -Off }
            if ($strictNative) { $scope.SessionState.PSVariable.Set('PSNativeCommandUseErrorActionPreference', $false) }
        }

        if ($null -ne $result.Error) {
            $native = $result.Error.Exception -as [System.Management.Automation.NativeCommandExitException]
            if ($null -ne $native) {
                $result.ExitCode = $native.ExitCode
                $result.Message = "exit status $($native.ExitCode)"
            }
            # Show the error like the failing command would have shown it.
            if ($mode -eq 'lines') {
                $lines = @($result.Error | Out-String -Stream | Where-Object { $_ -ne '' })
                if ($null -ne $buffer) { $buffer.AddRange([string[]]$lines) }
                else { [TaskLog]::Lines($ctx, @($lines | ForEach-Object { $target.Prefix + $_ })) }
            }
            else { [TaskLog]::Error($ctx, $result.Error) }
        }

        if ($null -ne $buffer -and -not ($ctx.Output.ErrorOnly -and $result.ExitCode -eq 0)) {
            $cache = [TaskCompiler]::NewCache($task.Vars)
            $lines = [List[string]]::new()
            if (-not [string]::IsNullOrEmpty($ctx.Output.Begin)) { $lines.Add([TaskCompiler]::Replace($cache, $ctx.Output.Begin, $null)) }
            $lines.AddRange($buffer)
            if (-not [string]::IsNullOrEmpty($ctx.Output.End)) { $lines.Add([TaskCompiler]::Replace($cache, $ctx.Output.End, $null)) }
            [System.Threading.Monitor]::Enter($ctx.OutputLock)
            try { [TaskLog]::Lines($ctx, $lines) }
            finally { [System.Threading.Monitor]::Exit($ctx.OutputLock) }
        }
        return $result
    }

    hidden static [void] RunCommand([hashtable] $ctx, [object] $task, [object] $call, [object] $cmd, [object] $scope) {
        $name = [TaskExecutor]::TaskName($task)
        if (-not [string]::IsNullOrEmpty($cmd.Task)) {
            [TaskExecutor]::Release($ctx)
            try {
                $calls = [List[object]]::new()
                $calls.Add([TaskExecutor]::NewCall($cmd.Task, $cmd.Vars, $cmd.Silent, $true, $call.Depth + 1))
                [TaskExecutor]::RunParallel($ctx, $calls, $false)
            }
            catch {
                if ([TaskScript]::IsStop($_.Exception) -or -not $cmd.IgnoreError) { throw }
                [TaskLog]::Verbose($ctx, "task: [$name] command error ignored: $([TaskError]::Unwrap($_.Exception).Message)")
            }
            finally { [TaskExecutor]::Acquire($ctx) }
            return
        }
        if ([string]::IsNullOrEmpty($cmd.Cmd)) { return }
        if (-not [TaskExecutor]::PlatformMatches($cmd.Platforms)) {
            [TaskLog]::Verbose($ctx, "task: [$name] $($cmd.Cmd) not for current platform - ignored")
            return
        }
        if (-not [string]::IsNullOrEmpty($cmd.If) -and -not $ctx.Options.Dry -and -not [TaskExecutor]::Condition($ctx, $task, $cmd.If, $cmd.ScriptVars)) {
            [TaskLog]::Verbose($ctx, "task: [$name] if condition not met - skipped")
            return
        }
        if ($ctx.Options.Verbose -or -not [TaskExecutor]::IsSilent($ctx, $task, $call, $cmd.Silent)) {
            [TaskLog]::Info($ctx, "task: [$name] $($cmd.Cmd)", 'Green')
        }
        if ($ctx.Options.Dry) { return }

        # The loop variables of this iteration (or EXIT_CODE of a deferred command).
        $null = [TaskScript]::SetVariables($scope, $cmd.ScriptVars)
        $result = [TaskExecutor]::RunScript($ctx, $task, $cmd, $scope)
        if ($result.ExitCode -eq 0) { return }
        if ($cmd.IgnoreError) {
            [TaskLog]::Verbose($ctx, "task: [$name] command error ignored: $($result.Message)")
            return
        }
        throw [TaskExecutor]::CommandError($result.Message, $result.ExitCode)
    }

    hidden static [void] RunDeps([hashtable] $ctx, [object] $task, [object] $call) {
        if ($task.Deps.Count -eq 0) { return }
        $calls = [List[object]]::new()
        foreach ($dep in $task.Deps) { $calls.Add([TaskExecutor]::NewCall($dep.Task, $dep.Vars, $dep.Silent, $true, $call.Depth + 1)) }
        [TaskExecutor]::Release($ctx)
        try { [TaskExecutor]::RunParallel($ctx, $calls, $ctx.Options.Failfast -or $task.Failfast) }
        finally { [TaskExecutor]::Acquire($ctx) }
    }

    # ---- task -----------------------------------------------------------------------------------

    hidden static [void] ExecuteCore([hashtable] $ctx, [object] $task, [object] $call) {
        $name = [TaskExecutor]::TaskName($task)
        [TaskLog]::Verbose($ctx, "task: `"$($call.Task)`" started")
        [TaskExecutor]::RunDeps($ctx, $task, $call)

        $skipFingerprinting = $ctx.Options.ForceAll -or (-not $call.Indirect -and $ctx.Options.Force)
        if (-not $skipFingerprinting) {
            $ctx.Cancel.Token.ThrowIfCancellationRequested()
            foreach ($precondition in $task.Preconditions) {
                if (-not [TaskExecutor]::Condition($ctx, $task, $precondition.Sh)) {
                    [TaskLog]::Info($ctx, "task: $($precondition.Msg)", 'Magenta')
                    throw [TaskError]::Create('task: precondition not met', [TaskError]::Unknown)
                }
            }
            if ([TaskExecutor]::IsUpToDate($ctx, $task)) {
                if ($ctx.Options.Verbose -or -not [TaskExecutor]::IsSilent($ctx, $task, $call, $false)) {
                    [TaskLog]::Info($ctx, "task: Task `"$name`" is up to date", 'Magenta')
                }
                return
            }
        }

        [TaskExecutor]::Prompt($ctx, $task)
        if (-not $ctx.Options.Dry -and -not [System.IO.Directory]::Exists($task.Dir)) {
            try { $null = [System.IO.Directory]::CreateDirectory($task.Dir) }
            catch { [TaskLog]::Info($ctx, "task: cannot make directory `"$($task.Dir)`": $($_.Exception.Message)", 'Red') }
        }

        # The variables become PowerShell variables once, when the scope is created: a command
        # may reassign them for the commands that follow.
        $scope = [TaskScript]::NewScope()
        foreach ($skipped in [TaskScript]::SetVariables($scope, $task.ScriptVars)) {
            [TaskLog]::Verbose($ctx, "task: [$name] variable `"$skipped`" is reserved by PowerShell and is not available as `$$skipped")
        }
        $deferred = [List[object]]::new()
        $exitCode = 0
        try {
            foreach ($cmd in $task.Cmds) {
                if ($cmd.Defer) { $deferred.Add($cmd); continue }
                $ctx.Cancel.Token.ThrowIfCancellationRequested()
                try { [TaskExecutor]::RunCommand($ctx, $task, $call, $cmd, $scope) }
                catch {
                    if ([TaskScript]::IsStop($_.Exception)) { throw }
                    $failure = [TaskError]::Unwrap($_.Exception)
                    try { [TaskFingerprint]::OnError($task, [TaskExecutor]::Method($ctx, $task), [string]$ctx.TempDir) } catch { }
                    if ($task.IgnoreError -and $null -ne $failure.Data['TaskExitStatus']) {
                        [TaskLog]::Verbose($ctx, "task: task error ignored: $($failure.Message)")
                        continue
                    }
                    if ($null -ne $failure.Data['TaskExitStatus']) { $exitCode = [int]$failure.Data['TaskExitStatus'] }
                    throw $failure
                }
            }
        }
        finally {
            for ($i = $deferred.Count - 1; $i -ge 0; $i--) {
                $extra = [OrderedDictionary]::new()
                if ($exitCode -gt 0) { $extra['EXIT_CODE'] = [string]$exitCode }
                $cache = [TaskCompiler]::NewCache($task.Vars)
                $cmd = [TaskCompiler]::ReplaceCmd($cache, $deferred[$i], $extra)
                try { [TaskExecutor]::RunCommand($ctx, $task, $call, $cmd, $scope) }
                catch {
                    if (-not [TaskScript]::IsStop($_.Exception)) {
                        [TaskLog]::Verbose($ctx, "task: ignored error in deferred cmd: $([TaskError]::Unwrap($_.Exception).Message)")
                    }
                }
            }
        }
        [TaskLog]::Verbose($ctx, "task: `"$($call.Task)`" finished")
    }

    hidden static [void] Execute([hashtable] $ctx, [object] $task, [object] $call) {
        try { [TaskExecutor]::ExecuteCore($ctx, $task, $call) }
        catch {
            if ([TaskScript]::IsStop($_.Exception)) { throw }
            $inner = [TaskError]::Unwrap($_.Exception)
            # Not wrapped: the message would repeat for every level of a runaway recursion.
            if ([TaskError]::CodeOf($inner) -eq [TaskError]::TaskCalledTooManyTimes) { throw $inner }
            $wrapped = [TaskError]::Create("task: Failed to run task `"$($task.Task)`": $($inner.Message)", [TaskError]::TaskRun, $inner)
            if ($null -ne $inner.Data['TaskExitStatus']) { $wrapped.Data['TaskExitStatus'] = $inner.Data['TaskExitStatus'] }
            throw $wrapped
        }
    }

    # Applies `run: once / when_changed`: identical executions run once, the others wait.
    hidden static [void] StartExecution([hashtable] $ctx, [object] $task, [object] $call) {
        $run = if (-not [string]::IsNullOrEmpty($task.Run)) { $task.Run } elseif (-not [string]::IsNullOrEmpty($ctx.Taskfile.Run)) { $ctx.Taskfile.Run } else { 'always' }
        $hash = ''
        if ($run -eq 'once') { $hash = "once:$($task.Task)" }
        elseif ($run -eq 'when_changed') { $hash = "when_changed:$($task.Task):" + [TplFn]::ToJson($task.Vars, $false, '', '') }
        elseif ($run -ne 'always') { throw [TaskError]::Create("task: invalid run `"$run`"", [TaskError]::TaskfileInvalid) }
        if ($hash.Length -eq 0) { [TaskExecutor]::Execute($ctx, $task, $call); return }

        $signal = [System.Threading.ManualResetEventSlim]::new($false)
        $existing = $ctx.ExecutionHashes.GetOrAdd($hash, $signal)
        if (-not [object]::ReferenceEquals($existing, $signal)) {
            $signal.Dispose()
            [TaskLog]::Verbose($ctx, "task: skipping execution of task: $($task.Task)")
            [TaskExecutor]::Release($ctx)
            try { while (-not $existing.Wait(50)) { $ctx.Cancel.Token.ThrowIfCancellationRequested() } }
            finally { [TaskExecutor]::Acquire($ctx) }
            return
        }
        try { [TaskExecutor]::Execute($ctx, $task, $call) }
        finally { $signal.Set() }
    }

    static [void] RunTask([hashtable] $ctx, [object] $call) {
        $ctx.Cancel.Token.ThrowIfCancellationRequested()
        $original = [TaskExecutor]::FindTask($ctx, $call)
        $fast = [TaskCompiler]::Compile($ctx, $original, $call, $false)
        if (-not [TaskExecutor]::PlatformMatches($fast.Platforms)) {
            [TaskLog]::Verbose($ctx, "task: `"$($call.Task)`" not for current platform - ignored")
            return
        }
        [TaskExecutor]::CheckRequiredVars($fast)

        $task = [TaskCompiler]::Compile($ctx, $original, $call, $true)
        [TaskExecutor]::CheckAllowedValues($task)

        $count = 0
        [System.Threading.Monitor]::Enter($ctx.Lock)
        try {
            $count = [int]$ctx.CallCounts[$task.Task] + 1
            $ctx.CallCounts[$task.Task] = $count
        }
        finally { [System.Threading.Monitor]::Exit($ctx.Lock) }
        if (-not $ctx.Options.Watch -and ($count -ge [TaskExecutor]::MaximumTaskCall -or $call.Depth -ge [TaskExecutor]::MaximumDepth)) {
            $message = "task: Maximum task call exceeded ($([TaskExecutor]::MaximumTaskCall)) for task `"$($task.Task)`": probably an cyclic dep or infinite loop"
            throw [TaskError]::Create($message, [TaskError]::TaskCalledTooManyTimes)
        }

        if (-not [string]::IsNullOrEmpty($task.If) -and -not $ctx.Options.Dry -and -not [TaskExecutor]::Condition($ctx, $task, $task.If)) {
            [TaskLog]::Verbose($ctx, "task: if condition not met - skipped: `"$($call.Task)`"")
            return
        }

        [TaskExecutor]::Acquire($ctx)
        try { [TaskExecutor]::StartExecution($ctx, $task, $call) }
        finally { [TaskExecutor]::Release($ctx) }
    }
}

[NoRunspaceAffinity()]
class TaskEngine {
    # Converts values passed from PowerShell (-Variable) to the engine's collection types.
    static [object] Normalize([object] $value) {
        $v = [TplFn]::Unwrap($value)
        if ($v -is [IDictionary]) {
            $dict = [OrderedDictionary]::new()
            foreach ($key in @($v.get_Keys())) { $dict[[string]$key] = [TaskEngine]::Normalize($v[$key]) }
            return $dict
        }
        if ([TplFn]::IsList($v)) {
            $list = [List[object]]::new()
            foreach ($item in $v) { $list.Add([TaskEngine]::Normalize($item)) }
            return $list
        }
        return $v
    }

    hidden static [string] QuoteArgument([string] $argument) {
        if ($argument.Length -gt 0 -and $argument -notmatch '[\s''"`$;|&(){}<>@#,]') { return $argument }
        return [TplFn]::ShellQuote($argument)
    }

    # Builds the shared run context from the request of the calling cmdlet.
    static [hashtable] NewContext([hashtable] $request) {
        $found = [TaskfileReader]::Find([string]$request.Taskfile, [string]$request.Directory, [bool]$request.Global, [string]$request.WorkingDirectory)
        $taskfile = [TaskfileReader]::Load($found.Entrypoint, $found.Dir)
        $options = $request.Options

        $tempDir = [Environment]::GetEnvironmentVariable('TASK_TEMP_DIR')
        if ([string]::IsNullOrEmpty($tempDir)) { $tempDir = '.task' }
        $tempDir = [TaskPath]::Full($found.Dir, $tempDir)

        $output = @{
            Name      = if (-not [string]::IsNullOrEmpty($options.Output)) { $options.Output } elseif ($taskfile.Output.Name.Length -gt 0) { $taskfile.Output.Name } else { 'interleaved' }
            Begin     = if (-not [string]::IsNullOrEmpty($options.OutputGroupBegin)) { $options.OutputGroupBegin } else { $taskfile.Output.Group.Begin }
            End       = if (-not [string]::IsNullOrEmpty($options.OutputGroupEnd)) { $options.OutputGroupEnd } else { $taskfile.Output.Group.End }
            ErrorOnly = $options.OutputGroupErrorOnly -or $taskfile.Output.Group.ErrorOnly
        }

        $ctx = @{
            Taskfile        = $taskfile
            Dir             = $found.Dir
            Entrypoint      = $found.Entrypoint
            UserWorkingDir  = [string]$request.WorkingDirectory
            Version         = [string]$request.Version
            TempDir         = $tempDir
            Options         = $options
            Output          = $output
            BaseEnv         = [TaskEnv]::Snapshot()
            EnvPrecedence   = [Environment]::GetEnvironmentVariable('TASK_X_ENV_PRECEDENCE') -eq '1'
            DynamicCache    = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new()
            ExecutionHashes = [System.Collections.Concurrent.ConcurrentDictionary[string, System.Threading.ManualResetEventSlim]]::new()
            CallCounts      = [hashtable]::new([StringComparer]::Ordinal)
            Lock            = [object]::new()
            OutputLock      = [object]::new()
            EnvGate         = [TaskEnv]::NewGate()
            Semaphore       = if ([int]$options.Concurrency -gt 0) { [System.Threading.SemaphoreSlim]::new([int]$options.Concurrency) } else { $null }
            Cancel          = [System.Threading.CancellationTokenSource]::new()
            Sink            = $request.Sink
            ModuleFile      = [string]$request.ModuleFile
            Host            = $global:Host
            IdleRunspaces   = [System.Collections.Concurrent.ConcurrentBag[object]]::new()
        }

        # Variables given on the command line override the global vars of the Taskfile.
        if ($null -ne $request.Variables) {
            foreach ($key in $request.Variables.get_Keys()) {
                $taskfile.Vars[[string]$key] = [TaskfileReader]::NewVar('static', [TaskEngine]::Normalize($request.Variables[$key]))
            }
        }
        $arguments = [List[object]]::new()
        $quoted = [List[string]]::new()
        foreach ($argument in @($request.ArgumentList)) {
            if ($null -eq $argument) { continue }
            $arguments.Add([string]$argument)
            $quoted.Add([TaskEngine]::QuoteArgument([string]$argument))
        }
        $taskfile.Vars['CLI_ARGS'] = [TaskfileReader]::NewVar('static', [string]::Join(' ', $quoted))
        $taskfile.Vars['CLI_ARGS_LIST'] = [TaskfileReader]::NewVar('static', $arguments)
        $taskfile.Vars['CLI_FORCE'] = [TaskfileReader]::NewVar('static', [bool]$options.Force)
        $taskfile.Vars['CLI_SILENT'] = [TaskfileReader]::NewVar('static', [bool]$options.Silent)
        $taskfile.Vars['CLI_VERBOSE'] = [TaskfileReader]::NewVar('static', [bool]$options.Verbose)
        $taskfile.Vars['CLI_OFFLINE'] = [TaskfileReader]::NewVar('static', $false)
        $taskfile.Vars['CLI_ASSUME_YES'] = [TaskfileReader]::NewVar('static', [bool]$options.AssumeYes)

        # dotenv files of the root Taskfile never override its env entries.
        if ($taskfile.Dotenv.Count -gt 0) {
            $cache = [TaskCompiler]::NewCache([TaskCompiler]::GetVariables($ctx, $null, $null, $true))
            foreach ($path in $taskfile.Dotenv) {
                $file = [TaskPath]::SmartJoin($found.Dir, [TaskPath]::ExpandHome([TaskCompiler]::Replace($cache, $path, $null)))
                if (-not [System.IO.File]::Exists($file)) { continue }
                $values = [TaskDotenv]::Read($file)
                foreach ($key in $values.get_Keys()) {
                    if (-not $taskfile.Env.Contains($key)) { $taskfile.Env[[string]$key] = [TaskfileReader]::NewVar('static', [string]$values[$key]) }
                }
            }
        }
        return $ctx
    }

    hidden static [void] Cleanup([hashtable] $ctx) {
        try { [TaskEnv]::Restore($ctx.BaseEnv) } catch { }
        $runspace = $null
        while ($ctx.IdleRunspaces.TryTake([ref] $runspace)) { try { $runspace.Dispose() } catch { } }
        foreach ($signal in $ctx.ExecutionHashes.get_Values()) { try { $signal.Dispose() } catch { } }
        if ($null -ne $ctx.Semaphore) { $ctx.Semaphore.Dispose() }
        $ctx.Cancel.Dispose()
    }

    hidden static [List[object]] NewCalls([hashtable] $ctx, [object] $names) {
        $calls = [List[object]]::new()
        foreach ($name in @($names)) { $calls.Add([TaskExecutor]::NewCall([string]$name, $null, $false, $false, 0)) }
        if ($calls.Count -eq 0) { $calls.Add([TaskExecutor]::NewCall('default', $null, $false, $false, 0)) }
        # Every requested task must exist and be callable before anything runs.
        foreach ($call in $calls) {
            $task = [TaskExecutor]::FindTask($ctx, $call)
            if ($task.Internal) { throw [TaskError]::Create("task: Task `"$($call.Task)`" is internal", [TaskError]::TaskInternal) }
        }
        return $calls
    }

    # Prints the tasks with a description in the layout of `task --list`.
    hidden static [void] PrintTaskList([hashtable] $ctx) {
        $tasks = [TaskEngine]::ListTasks($ctx, @{ Names = @(); All = $false; Sort = 'default'; IncludeStatus = $false })
        if ($tasks.Count -eq 0) {
            [TaskLog]::Info($ctx, 'task: No tasks with description available. Try Get-Task -All to list all tasks', 'Yellow')
            return
        }
        [TaskLog]::Info($ctx, 'task: Available tasks for this project:', '')
        $nameWidth = 0
        foreach ($task in $tasks) { $nameWidth = [Math]::Max($nameWidth, "* $($task.Name): ".Length) }
        # The description column is aligned among consecutive tasks that have aliases.
        $descriptionWidth = [int[]]::new($tasks.Count)
        $i = 0
        while ($i -lt $tasks.Count) {
            if ($tasks[$i].Aliases.Count -eq 0) { $i++; continue }
            $end = $i
            $width = 0
            while ($end -lt $tasks.Count -and $tasks[$end].Aliases.Count -gt 0) { $width = [Math]::Max($width, $tasks[$end].Description.Length); $end++ }
            for ($k = $i; $k -lt $end; $k++) { $descriptionWidth[$k] = $width + 6 }
            $i = $end
        }
        for ($i = 0; $i -lt $tasks.Count; $i++) {
            $task = $tasks[$i]
            $line = "* $($task.Name): ".PadRight($nameWidth + 6)
            if ($task.Aliases.Count -gt 0) { $line += $task.Description.PadRight($descriptionWidth[$i]) + "(aliases: $([string]::Join(', ', $task.Aliases)))" }
            else { $line += $task.Description }
            [TaskLog]::Info($ctx, $line, '')
        }
    }

    hidden static [object] RunTasks([hashtable] $ctx, [hashtable] $request) {
        $calls = $null
        try { $calls = [TaskEngine]::NewCalls($ctx, $request.Names) }
        catch {
            # Like `task`, show what is available when the name is unknown.
            $failure = [TaskError]::Unwrap($_.Exception)
            if ([TaskError]::CodeOf($failure) -eq [TaskError]::TaskNotFound) { try { [TaskEngine]::PrintTaskList($ctx) } catch { } }
            throw $failure
        }
        # Tasks marked `watch: true` (or everything with -Watch) are watched after the others ran.
        $regular = [List[object]]::new()
        $watched = [List[object]]::new()
        foreach ($call in $calls) {
            if ($ctx.Options.Watch -or [TaskExecutor]::FindTask($ctx, $call).Watch) { $watched.Add($call) } else { $regular.Add($call) }
        }
        if ($ctx.Options.Parallel) { [TaskExecutor]::RunParallel($ctx, $regular, [bool]$ctx.Options.Failfast) }
        else { foreach ($call in $regular) { [TaskExecutor]::RunTask($ctx, $call) } }
        if ($watched.Count -gt 0) {
            $ctx.Options.Watch = $true
            [TaskEngine]::WatchTasks($ctx, $watched)
        }
        return $null
    }

    # ---- watch ---------------------------------------------------------------------------------

    hidden static [void] CollectSources([hashtable] $ctx, [string] $name, [HashSet[string]] $visited, [SortedSet[string]] $files) {
        if (-not $visited.Add($name)) { return }
        $task = $null
        try {
            $call = [TaskExecutor]::NewCall($name, $null, $false, $true, 0)
            $task = [TaskCompiler]::Compile($ctx, [TaskExecutor]::FindTask($ctx, $call), $call, $false)
        }
        catch { return }
        foreach ($file in [TaskGlob]::Expand($task.Dir, $task.Sources)) { [void]$files.Add($file) }
        foreach ($dep in $task.Deps) { [TaskEngine]::CollectSources($ctx, $dep.Task, $visited, $files) }
        foreach ($cmd in $task.Cmds) {
            if (-not [string]::IsNullOrEmpty($cmd.Task)) { [TaskEngine]::CollectSources($ctx, $cmd.Task, $visited, $files) }
        }
    }

    # A fingerprint of every source file of the watched tasks (and of the tasks they use).
    hidden static [string] WatchSignature([hashtable] $ctx, [List[object]] $calls) {
        $files = [SortedSet[string]]::new([StringComparer]::Ordinal)
        $visited = [HashSet[string]]::new([StringComparer]::Ordinal)
        foreach ($call in $calls) { [TaskEngine]::CollectSources($ctx, $call.Task, $visited, $files) }
        $sb = [System.Text.StringBuilder]::new()
        foreach ($file in $files) {
            $info = [System.IO.FileInfo]::new($file)
            if ($info.Exists) { [void]$sb.Append($file).Append('|').Append($info.LastWriteTimeUtc.Ticks).Append('|').Append($info.Length).Append("`n") }
        }
        return $sb.ToString()
    }

    hidden static [int] WatchInterval([hashtable] $ctx) {
        $text = [string]$ctx.Options.Interval
        if ([string]::IsNullOrEmpty($text)) { $text = [string]$ctx.Taskfile.Interval }
        if ([string]::IsNullOrEmpty($text)) { return 500 }
        $milliseconds = 0
        try { $milliseconds = [int][TplFn]::ParseDuration($text).TotalMilliseconds }
        catch { throw [TaskError]::Create("task: invalid interval `"$text`" (use a duration such as 500ms or 2s)", [TaskError]::TaskfileInvalid) }
        return [Math]::Max($milliseconds, 20)
    }

    # Runs the calls and returns $true when a source changed before they finished.
    hidden static [bool] RunWatched([hashtable] $ctx, [List[object]] $calls, [string] $signature, [int] $interval) {
        foreach ($call in $calls) {
            $branch = [TaskExecutor]::StartBranch($ctx, [TaskExecutor]::NewCall($call.Task, $call.Vars, $call.Silent, $false, 0))
            $finished = $false
            try {
                while (-not $branch.Handle.IsCompleted) {
                    Start-Sleep -Milliseconds ([Math]::Min($interval, 100))
                    if ([TaskEngine]::WatchSignature($ctx, $calls) -cne $signature) { return $true }
                }
                $finished = $true
                $output = $branch.Shell.EndInvoke($branch.Handle)
                if ($output.Count -gt 0 -and -not $output[0].Ok) { [TaskLog]::Info($ctx, [string]$output[0].Message, 'Red') }
            }
            finally {
                if (-not $finished) { try { $branch.Shell.Stop() } catch { } }
                $branch.Shell.Dispose()
                $branch.Runspace.Dispose()
            }
        }
        return $false
    }

    # Runs the tasks, then again whenever one of their sources changes. Only stops when the
    # caller is stopped (Ctrl+C).
    hidden static [void] WatchTasks([hashtable] $ctx, [List[object]] $calls) {
        $names = [List[string]]::new()
        foreach ($call in $calls) { $names.Add($call.Task) }
        $interval = [TaskEngine]::WatchInterval($ctx)
        [TaskLog]::Info($ctx, "task: Started watching for tasks: $([string]::Join(', ', $names))", 'Green')
        while ($true) {
            $signature = [TaskEngine]::WatchSignature($ctx, $calls)
            foreach ($signal in $ctx.ExecutionHashes.get_Values()) { try { $signal.Dispose() } catch { } }
            $ctx.ExecutionHashes.Clear()
            $ctx.CallCounts.Clear()
            $ctx.DynamicCache.Clear()
            if ([TaskEngine]::RunWatched($ctx, $calls, $signature, $interval)) { continue }
            while ([TaskEngine]::WatchSignature($ctx, $calls) -ceq $signature) { Start-Sleep -Milliseconds $interval }
        }
    }

    # ---- listing, summary and status -------------------------------------------------------------

    hidden static [bool] IsNamespaced([string] $name) { return $name.Contains(':') }

    # Tasks for Get-Task: never internal ones; without -All only tasks with a description
    # (unless they were asked for by name).
    hidden static [object] ListTasks([hashtable] $ctx, [hashtable] $request) {
        $patterns = [List[System.Management.Automation.WildcardPattern]]::new()
        foreach ($name in @($request.Names)) {
            if (-not [string]::IsNullOrEmpty($name)) { $patterns.Add([System.Management.Automation.WildcardPattern]::new([string]$name, 'IgnoreCase')) }
        }
        $selected = [List[object]]::new()
        foreach ($task in $ctx.Taskfile.Tasks.get_Values()) {
            if ($task.Internal) { continue }
            if ($patterns.Count -gt 0) {
                $matched = $false
                foreach ($pattern in $patterns) {
                    foreach ($candidate in @($task.Task) + @($task.Aliases)) { if ($pattern.IsMatch($candidate)) { $matched = $true } }
                }
                if (-not $matched) { continue }
            }
            elseif (-not $request.All -and [string]::IsNullOrEmpty($task.Desc)) { continue }
            $selected.Add($task)
        }

        $sort = [string]$request.Sort
        if ($sort -ne 'none') {
            $names = [List[string]]::new()
            foreach ($task in $selected) { $names.Add($task.Task) }
            $array = $names.ToArray()
            [Array]::Sort($array, [StringComparer]::Ordinal)
            $ordered = [List[object]]::new()
            if ($sort -ne 'alphanumeric') {
                # default: tasks of the root Taskfile first, then the namespaced ones
                foreach ($name in $array) { if (-not [TaskEngine]::IsNamespaced($name)) { $ordered.Add($ctx.Taskfile.Tasks[$name]) } }
                foreach ($name in $array) { if ([TaskEngine]::IsNamespaced($name)) { $ordered.Add($ctx.Taskfile.Tasks[$name]) } }
            }
            else { foreach ($name in $array) { $ordered.Add($ctx.Taskfile.Tasks[$name]) } }
            $selected = $ordered
        }

        # Status checks for a listing never update the stored fingerprints.
        if ($request.IncludeStatus) { $ctx.Options.Dry = $true }
        $result = [List[object]]::new()
        foreach ($task in $selected) {
            $call = [TaskExecutor]::NewCall($task.Task, $null, $false, $false, 0)
            $compiled = [TaskCompiler]::Compile($ctx, $task, $call, $false)
            $upToDate = $null
            if ($request.IncludeStatus) {
                try { $upToDate = [TaskExecutor]::IsUpToDate($ctx, $compiled) } catch { $upToDate = $false }
            }
            $result.Add([pscustomobject]@{
                    Name        = $task.Task
                    Label       = $compiled.Label
                    Description = $compiled.Desc
                    Summary     = $compiled.Summary
                    Aliases     = $task.Aliases
                    UpToDate    = $upToDate
                    Location    = [pscustomobject]@{ Taskfile = $task.Location.Taskfile; Line = $task.Location.Line; Column = $task.Location.Column }
                })
        }
        return $result
    }

    # Value of a variable as `task --summary` prints it.
    hidden static [string] FormatVar([hashtable] $cache, [object] $var) {
        if ($var.Kind -eq 'sh' -and -not [string]::IsNullOrEmpty($var.Sh)) { return "sh: $([TaskCompiler]::Replace($cache, $var.Sh, $null))" }
        $value = $null
        if ($var.Kind -eq 'ref') { $value = [TaskCompiler]::ResolveRef($cache, $var.Ref, $null) }
        else { $value = [TaskCompiler]::ReplaceValue($cache, $var.Value, $null) }
        $value = [TplFn]::Unwrap($value)
        if ($null -eq $value) { return '""' }
        if ($value -is [IDictionary]) {
            $text = [System.Text.StringBuilder]::new()
            foreach ($key in $value.get_Keys()) { [void]$text.Append("`n ${key}: $([TplFn]::Format($value[$key], $false))") }
            return $text.Append("`n").ToString()
        }
        return '"' + [TplFn]::Format($value, $false) + '"'
    }

    hidden static [void] AddVarLines([hashtable] $ctx, [List[string]] $lines, [string] $title, [OrderedDictionary] $vars, [hashtable] $cache, [string[]] $skip) {
        $entries = [List[string]]::new()
        foreach ($key in $vars.get_Keys()) {
            $name = [string]$key
            if ($ctx.BaseEnv.ContainsKey($name) -or $skip -ccontains $name) { continue }
            $entries.Add(" ${name}: $([TaskEngine]::FormatVar($cache, $vars[$key]))")
        }
        if ($entries.Count -eq 0) { return }
        $lines.Add('')
        $lines.Add("${title}:")
        foreach ($entry in $entries) { foreach ($line in $entry.Split("`n")) { $lines.Add($line) } }
    }

    # The text of `task --summary` for each task.
    hidden static [object] Summarize([hashtable] $ctx, [hashtable] $request) {
        $lines = [List[string]]::new()
        $calls = [TaskEngine]::NewCalls($ctx, $request.Names)
        $cliVars = @('CLI_ARGS', 'CLI_ARGS_LIST', 'CLI_FORCE', 'CLI_SILENT', 'CLI_VERBOSE', 'CLI_OFFLINE', 'CLI_ASSUME_YES')
        for ($i = 0; $i -lt $calls.Count; $i++) {
            if ($i -gt 0) { $lines.Add(''); $lines.Add('') }
            $call = $calls[$i]
            $original = [TaskExecutor]::FindTask($ctx, $call)
            $task = [TaskCompiler]::Compile($ctx, $original, $call, $false)
            $cache = [TaskCompiler]::NewCache($task.Vars)

            $lines.Add("task: $([TaskExecutor]::TaskName($task))")
            $lines.Add('')
            if (-not [string]::IsNullOrEmpty($task.Summary)) {
                $summary = $task.Summary.Replace("`r`n", "`n").Split("`n")
                for ($k = 0; $k -lt $summary.Count; $k++) {
                    if ($k + 1 -lt $summary.Count -or $summary[$k].Length -gt 0) { $lines.Add($summary[$k]) }
                }
            }
            elseif (-not [string]::IsNullOrEmpty($task.Desc)) { $lines.Add($task.Desc) }
            else { $lines.Add('(task does not have description or summary)') }

            $vars = [OrderedDictionary]::new()
            foreach ($key in $ctx.Taskfile.Vars.get_Keys()) { $vars[[string]$key] = $ctx.Taskfile.Vars[$key] }
            foreach ($key in $original.Vars.get_Keys()) { $vars[[string]$key] = $original.Vars[$key] }
            $environment = [OrderedDictionary]::new()
            foreach ($key in $ctx.Taskfile.Env.get_Keys()) { $environment[[string]$key] = $ctx.Taskfile.Env[$key] }
            foreach ($key in $original.Env.get_Keys()) { $environment[[string]$key] = $original.Env[$key] }
            $environmentNames = [List[string]]::new()
            foreach ($key in $environment.get_Keys()) { $environmentNames.Add([string]$key) }
            [TaskEngine]::AddVarLines($ctx, $lines, 'vars', $vars, $cache, [string[]]($cliVars + $environmentNames.ToArray()))
            [TaskEngine]::AddVarLines($ctx, $lines, 'env', $environment, $cache, [string[]]@())

            if ($task.Requires.Count -gt 0) {
                $lines.Add('')
                $lines.Add('requires:')
                $lines.Add(' vars:')
                foreach ($required in $task.Requires) {
                    if ($null -ne $required.Enum -and $required.Enum.Count -gt 0) {
                        $lines.Add(" - $($required.Name):")
                        $lines.Add(' enum:')
                        foreach ($value in $required.Enum) { $lines.Add(" - $value") }
                    }
                    else { $lines.Add(" - $($required.Name)") }
                }
            }
            if ($task.Deps.Count -gt 0) {
                $lines.Add('')
                $lines.Add('dependencies:')
                foreach ($dep in $task.Deps) { $lines.Add(" - $($dep.Task)") }
            }
            if ($task.Aliases.Count -gt 0) {
                $lines.Add('')
                $lines.Add('aliases:')
                foreach ($alias in $task.Aliases) { $lines.Add(" - $alias") }
            }
            if ($task.Cmds.Count -gt 0) {
                $lines.Add('')
                $lines.Add('commands:')
                foreach ($cmd in $task.Cmds) {
                    if (-not [string]::IsNullOrEmpty($cmd.Cmd)) { $lines.Add(" - $($cmd.Cmd)") } else { $lines.Add(" - Task: $($cmd.Task)") }
                }
            }
        }
        return $lines
    }

    # Whether each named task is up to date (Test-Task). Never updates the stored state.
    hidden static [object] Status([hashtable] $ctx, [hashtable] $request) {
        $ctx.Options.Dry = $true
        $result = [List[object]]::new()
        foreach ($call in [TaskEngine]::NewCalls($ctx, $request.Names)) {
            $task = [TaskCompiler]::Compile($ctx, [TaskExecutor]::FindTask($ctx, $call), $call, $true)
            $result.Add([TaskExecutor]::IsUpToDate($ctx, $task))
        }
        return $result
    }

    # Entry point of the engine runspace. Never throws for task failures: the outcome is
    # returned as data (Ok / Message / Code / ExitStatus) for the calling cmdlet to report.
    static [hashtable] Run([hashtable] $request) {
        $ctx = $null
        try {
            $ctx = [TaskEngine]::NewContext($request)
            $data = $null
            switch ([string]$request.Action) {
                'Run' { $data = [TaskEngine]::RunTasks($ctx, $request) }
                'List' { $data = [TaskEngine]::ListTasks($ctx, $request) }
                'Summary' { $data = [TaskEngine]::Summarize($ctx, $request) }
                'Status' { $data = [TaskEngine]::Status($ctx, $request) }
                default { throw [TaskError]::Create("task: unknown action `"$($request.Action)`"", [TaskError]::Unknown) }
            }
            return @{ Ok = $true; Data = $data }
        }
        catch {
            if ([TaskScript]::IsStop($_.Exception)) { throw }
            $exception = [TaskError]::Unwrap($_.Exception)
            return @{
                Ok         = $false
                Message    = $exception.Message
                Code       = [TaskError]::CodeOf($exception)
                ExitStatus = $exception.Data['TaskExitStatus']
                Exception  = $exception
            }
        }
        finally {
            if ($null -ne $ctx) { [TaskEngine]::Cleanup($ctx) }
        }
    }
}