workflows/default/systems/runtime/launch-process.ps1
|
<# .SYNOPSIS Unified process launcher replacing both loop scripts and ad-hoc Start-Job calls. .DESCRIPTION Every Claude invocation is a tracked process. Creates a process registry entry, builds the appropriate prompt, invokes Claude, and manages the lifecycle. .PARAMETER Type Process type: analysis, execution, kickstart, planning, commit, task-creation .PARAMETER TaskId Optional: specific task ID (for analysis/execution types) .PARAMETER Prompt Optional: custom prompt text (for kickstart/planning/commit/task-creation) .PARAMETER Continue If set, continue to next task after completion (analysis/execution only) .PARAMETER Model Claude model to use (default: Opus) .PARAMETER ShowDebug Show raw JSON events .PARAMETER ShowVerbose Show detailed tool results .PARAMETER MaxTasks Max tasks to process with -Continue (0 = unlimited) .PARAMETER Description Human-readable description for UI display .PARAMETER ProcessId Optional: resume an existing process by ID (skips creation) .PARAMETER NoWait If set with -Continue, exit when no tasks available instead of waiting. Used by kickstart pipeline to prevent workflow children from blocking phase progression. #> param( [Parameter(Mandatory)] [ValidateSet('analysis', 'execution', 'task-runner', 'kickstart', 'analyse', 'planning', 'commit', 'task-creation')] [string]$Type, [string]$TaskId, [string]$Prompt, [switch]$Continue, [string]$Model, [switch]$ShowDebug, [switch]$ShowVerbose, [int]$MaxTasks = 0, [string]$Description, [string]$ProcessId, [switch]$NeedsInterview, [switch]$AutoWorkflow, [switch]$NoWait, [string]$FromPhase, [string]$SkipPhases, # comma-separated phase IDs to skip [string]$Workflow, # filter task queue to this workflow name [ValidateRange(-1, 16)] [int]$Slot = -1 # concurrent slot index (-1 = single instance, 0..N = multi-slot) ) Set-StrictMode -Version 1.0 # Parse skip phases $skipPhaseIds = if ($SkipPhases) { $SkipPhases -split ',' } else { @() } # --- Configuration --- # Determine phase for activity logging $phaseMap = @{ 'analysis' = 'analysis' 'execution' = 'execution' 'task-runner' = 'task-runner' 'kickstart' = 'execution' 'analyse' = 'execution' 'planning' = 'execution' 'commit' = 'execution' 'task-creation' = 'execution' } $env:DOTBOT_CURRENT_PHASE = $phaseMap[$Type] # Resolve paths $botRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) $controlDir = Join-Path $botRoot ".control" $processesDir = Join-Path $controlDir "processes" $projectRoot = Split-Path -Parent $botRoot $global:DotbotProjectRoot = $projectRoot # Ensure directories exist if (-not (Test-Path $processesDir)) { New-Item -Path $processesDir -ItemType Directory -Force | Out-Null } $logsDir = Join-Path $controlDir "logs" if (-not (Test-Path $logsDir)) { New-Item -Path $logsDir -ItemType Directory -Force | Out-Null } # Import DotBotLog FIRST — before all other modules so they can use Write-BotLog Import-Module "$PSScriptRoot\modules\DotBotLog.psm1" -Force -DisableNameChecking Initialize-DotBotLog -LogDir $logsDir -ControlDir $controlDir -ProjectRoot $projectRoot # Validate TaskId format when provided (after DotBotLog import so we can log properly) if ($TaskId -and $TaskId -notmatch '^[a-f0-9]{8}$') { Write-BotLog -Level Warn -Message "TaskId '$TaskId' does not match expected format (8-char hex). Proceeding anyway." } # Import modules Import-Module "$PSScriptRoot\ProviderCLI\ProviderCLI.psm1" -Force Import-Module "$PSScriptRoot\modules\DotBotTheme.psm1" -Force Import-Module "$PSScriptRoot\modules\InstanceId.psm1" -Force $t = Get-DotBotTheme # Set canonical version from version.json (available to all child scripts) if (-not $env:DOTBOT_VERSION) { $versionFile = Join-Path (Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))) 'version.json' if (Test-Path $versionFile) { try { $env:DOTBOT_VERSION = (Get-Content $versionFile -Raw | ConvertFrom-Json).version } catch { Write-BotLog -Level Debug -Message "Non-critical operation failed" -Exception $_ } } } . "$PSScriptRoot\modules\prompt-builder.ps1" . "$PSScriptRoot\modules\rate-limit-handler.ps1" # Import task-based modules for analysis/execution/workflow types if ($Type -in @('analysis', 'execution', 'task-runner')) { Import-Module "$PSScriptRoot\..\mcp\modules\TaskIndexCache.psm1" -Force Import-Module "$PSScriptRoot\..\mcp\modules\SessionTracking.psm1" -Force . "$PSScriptRoot\modules\cleanup.ps1" . "$PSScriptRoot\modules\get-failure-reason.ps1" Import-Module "$PSScriptRoot\modules\WorktreeManager.psm1" -Force . "$PSScriptRoot\modules\test-task-completion.ps1" # MCP tool functions — load ALL tools dynamically (includes workflow-specific ones) $mcpToolsDir = Join-Path $PSScriptRoot "..\mcp\tools" Get-ChildItem -Path $mcpToolsDir -Directory | ForEach-Object { $toolScript = Join-Path $_.FullName "script.ps1" if (Test-Path $toolScript) { . $toolScript } } } # Load settings for model defaults $settingsPath = Join-Path $botRoot "settings\settings.default.json" $settings = @{ execution = @{ model = 'Opus' }; analysis = @{ model = 'Opus' } } if (Test-Path $settingsPath) { try { $settings = Get-Content $settingsPath -Raw | ConvertFrom-Json } catch { Write-BotLog -Level Warn -Message "Failed to load settings" -Exception $_ } } # Re-initialize structured logging with actual settings $logSettings = $settings.logging if ($logSettings) { Initialize-DotBotLog -LogDir $logsDir -ControlDir $controlDir -ProjectRoot $projectRoot ` -FileLevel ($logSettings.file_level ?? 'Debug') ` -ConsoleLevel ($logSettings.console_level ?? 'Info') ` -RetentionDays ($logSettings.retention_days ?? 7) ` -MaxFileSizeMB ($logSettings.max_file_size_mb ?? 50) ` -FileRetryCount ($settings.operations.file_retry_count ?? 3) ` -FileRetryBaseMs ($settings.operations.file_retry_base_ms ?? 50) } # Workspace instance ID (stable per .bot workspace). # For legacy projects missing this field, create and persist one. $instanceId = Get-OrCreateWorkspaceInstanceId -SettingsPath $settingsPath if (-not $instanceId) { $instanceId = "" } # Override model selections from UI settings (ui-settings.json) $uiSettings = $null $uiSettingsPath = Join-Path $botRoot ".control\ui-settings.json" if (Test-Path $uiSettingsPath) { try { $uiSettings = Get-Content $uiSettingsPath -Raw | ConvertFrom-Json if ($uiSettings.analysisModel) { $settings.analysis.model = $uiSettings.analysisModel } if ($uiSettings.executionModel) { $settings.execution.model = $uiSettings.executionModel } } catch { Write-BotLog -Level Debug -Message "Failed to parse data" -Exception $_ } } # Load provider config $providerConfig = Get-ProviderConfig # Resolve permission mode (ui-settings > settings.default > provider default) $permissionMode = $null if ($uiSettings -and $uiSettings.permissionMode) { $permissionMode = $uiSettings.permissionMode } elseif ($settings.permission_mode) { $permissionMode = $settings.permission_mode } if ($permissionMode -and $providerConfig.permission_modes -and -not $providerConfig.permission_modes.$permissionMode) { Write-BotLog -Level Warn -Message "Permission mode '$permissionMode' not valid for active provider. Using provider default." $permissionMode = $null } if (-not $permissionMode -and $providerConfig.default_permission_mode) { $permissionMode = $providerConfig.default_permission_mode } # Resolve model (parameter > settings > provider default) if (-not $Model) { $Model = switch ($Type) { { $_ -in @('analysis', 'kickstart') } { if ($settings.analysis?.model) { $settings.analysis.model } else { $providerConfig.default_model } } 'task-runner' { if ($settings.execution?.model) { $settings.execution.model } else { $providerConfig.default_model } } default { if ($settings.execution?.model) { $settings.execution.model } else { $providerConfig.default_model } } } } try { $claudeModelName = Resolve-ProviderModelId -ModelAlias $Model } catch { Write-BotLog -Level Warn -Message "Model '$Model' not valid for active provider. Falling back to '$($providerConfig.default_model)'." $claudeModelName = Resolve-ProviderModelId -ModelAlias $providerConfig.default_model } # Validate model against permission mode restrictions (e.g. Haiku excluded in auto mode) if ($permissionMode -and $providerConfig.permission_modes -and $providerConfig.permission_modes.$permissionMode) { $modeConfig = $providerConfig.permission_modes.$permissionMode if ($modeConfig.restrictions -and $modeConfig.restrictions.excluded_models) { $excluded = @($modeConfig.restrictions.excluded_models) if ($Model -in $excluded) { Write-BotLog -Level Warn -Message "Model '$Model' is not supported with permission mode '$permissionMode'. Remapping to '$($providerConfig.default_model)'." $Model = $providerConfig.default_model $claudeModelName = Resolve-ProviderModelId -ModelAlias $Model } } } $env:CLAUDE_MODEL = $claudeModelName $env:DOTBOT_MODEL = $claudeModelName # --- Process Registry (module) --- Import-Module "$PSScriptRoot\modules\ProcessRegistry.psm1" -Force Initialize-ProcessRegistry ` -ProcessesDir $processesDir ` -ControlDir $controlDir ` -Settings $settings ` -ProviderConfig $providerConfig ` -BotRoot $botRoot # --- Interview Loop (dot-sourced for kickstart) --- . "$PSScriptRoot\modules\InterviewLoop.ps1" # Early-initialize variables used by the crash trap (must be set before trap registration) $procId = if ($ProcessId) { $ProcessId } else { New-ProcessId } $lockKey = if ($Slot -ge 0) { "$Type-$Slot" } else { $Type } # --- Crash Trap --- # Catch unexpected termination and persist process state before exit trap { if ((Test-Path variable:procId) -and $procId -and (Test-Path variable:processData) -and $processData -and $processData.status -in @('running', 'starting')) { $processData.status = 'stopped' $processData.failed_at = (Get-Date).ToUniversalTime().ToString("o") $processData.error = "Unexpected termination: $($_.Exception.Message)" try { Write-ProcessFile -Id $procId -Data $processData } catch { Write-BotLog -Level Debug -Message "Non-critical operation failed" -Exception $_ } try { Write-ProcessActivity -Id $procId -ActivityType "text" -Message "Process terminated unexpectedly: $($_.Exception.Message)" } catch { Write-BotLog -Level Warn -Message "Failed to write process activity" -Exception $_ } } if (Test-Path variable:lockKey) { try { Remove-ProcessLock -LockType $lockKey } catch { Write-BotLog -Level Debug -Message "Logging operation failed" -Exception $_ } } } # --- Preflight checks --- $preflight = Test-Preflight if (-not $preflight.passed) { Write-BotLog -Level Warn -Message "Preflight checks failed:" foreach ($check in $preflight.checks) { if ($check -match 'MISSING') { Write-BotLog -Level Warn -Message " $check" } } exit 1 } # --- Single-instance guard (slot-aware) --- if (-not (Acquire-ProcessLock -LockType $lockKey)) { $lockPath = Join-Path $controlDir "launch-$lockKey.lock" $existingPid = if (Test-Path $lockPath) { (Get-Content $lockPath -Raw -ErrorAction SilentlyContinue)?.Trim() } else { "unknown" } Write-BotLog -Level Warn -Message "Another $lockKey process is already running (PID $existingPid). Exiting." exit 1 } # --- Initialize Process --- $sessionId = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH-mm-ssZ") $claudeSessionId = New-ProviderSession # Set process ID and correlation ID env vars for structured logging $env:DOTBOT_PROCESS_ID = $procId if (-not $env:DOTBOT_CORRELATION_ID) { $env:DOTBOT_CORRELATION_ID = "corr-$([guid]::NewGuid().ToString().Substring(0,8))" } $processData = @{ id = $procId correlation_id = $env:DOTBOT_CORRELATION_ID type = $Type status = 'starting' task_id = $TaskId task_name = $null continue = [bool]$Continue no_wait = [bool]$NoWait model = $Model pid = $PID session_id = $sessionId claude_session_id = $claudeSessionId started_at = (Get-Date).ToUniversalTime().ToString("o") last_heartbeat = (Get-Date).ToUniversalTime().ToString("o") heartbeat_status = "Starting $Type process" heartbeat_next_action = $null last_whisper_index = 0 completed_at = $null failed_at = $null tasks_completed = 0 error = $null workflow = $null workflow_name = if ($Workflow) { $Workflow } else { $null } description = $Description phases = @() skip_phases = $skipPhaseIds } Write-ProcessFile -Id $procId -Data $processData # Initialize diagnostic log (update module with diag path now that procId is known) $script:diagLogPath = Join-Path $controlDir "diag-$procId.log" Initialize-ProcessRegistry ` -ProcessesDir $processesDir ` -ControlDir $controlDir ` -DiagLogPath $script:diagLogPath ` -Settings $settings ` -ProviderConfig $providerConfig ` -BotRoot $botRoot Write-Diag "=== Process started: Type=$Type, ProcId=$procId, PID=$PID, Continue=$Continue, NoWait=$NoWait ===" Write-Diag "BotRoot=$botRoot | ProcessesDir=$processesDir | ProjectRoot=$projectRoot" $procFilePath = Join-Path $processesDir "$procId.json" Write-Diag "Process file exists: $(Test-Path $procFilePath) at $procFilePath" # Banner Write-Card -Title "PROCESS: $($Type.ToUpper())" -Width 50 -BorderStyle Rounded -BorderColor Label -TitleColor Label -Lines @( "$($t.Label)ID:$($t.Reset) $($t.Cyan)$procId$($t.Reset)" "$($t.Label)Model:$($t.Reset) $($t.Purple)$Model$($t.Reset)" "$($t.Label)Type:$($t.Reset) $($t.Amber)$Type$($t.Reset)" ) Write-ProcessActivity -Id $procId -ActivityType "text" -Message "Process $procId started ($Type)" Write-ProcessActivity -Id $procId -ActivityType "text" -Message "Preflight OK: $($preflight.checks -join '; ')" # --- Task-based types: analysis/execution --- if ($Type -in @('analysis', 'execution', 'analyse')) { $ctx = @{ Type = $Type BotRoot = $botRoot ProcId = $procId ProcessData = $processData ModelName = $claudeModelName SessionId = $claudeSessionId ShowDebug = [bool]$ShowDebug ShowVerbose = [bool]$ShowVerbose ProjectRoot = $projectRoot ProcessesDir = $processesDir ControlDir = $controlDir Settings = $settings Model = $Model BatchSessionId = $sessionId InstanceId = $instanceId Continue = [bool]$Continue NoWait = [bool]$NoWait MaxTasks = $MaxTasks TaskId = $TaskId PermissionMode = $permissionMode } if ($Type -in @('analysis', 'analyse')) { & "$PSScriptRoot\modules\ProcessTypes\Invoke-AnalysisProcess.ps1" -Context $ctx } else { & "$PSScriptRoot\modules\ProcessTypes\Invoke-ExecutionProcess.ps1" -Context $ctx } } # --- Task Runner type: unified analyse-then-execute per task --- elseif ($Type -eq 'task-runner') { $ctx = @{ Type = $Type BotRoot = $botRoot ProcId = $procId ProcessData = $processData ModelName = $claudeModelName SessionId = $claudeSessionId ShowDebug = [bool]$ShowDebug ShowVerbose = [bool]$ShowVerbose ProjectRoot = $projectRoot ProcessesDir = $processesDir ControlDir = $controlDir Settings = $settings Model = $Model BatchSessionId = $sessionId InstanceId = $instanceId Continue = [bool]$Continue NoWait = [bool]$NoWait MaxTasks = $MaxTasks TaskId = $TaskId Slot = $Slot Workflow = $Workflow PermissionMode = $permissionMode } & "$PSScriptRoot\modules\ProcessTypes\Invoke-WorkflowProcess.ps1" -Context $ctx } # --- Kickstart type: three-phase product setup --- elseif ($Type -eq 'kickstart') { $ctx = @{ Type = $Type BotRoot = $botRoot ProcId = $procId ProcessData = $processData ModelName = $claudeModelName SessionId = $claudeSessionId Prompt = $Prompt Description = $Description ShowDebug = [bool]$ShowDebug ShowVerbose = [bool]$ShowVerbose ProjectRoot = $projectRoot ControlDir = $controlDir Settings = $settings Model = $Model NeedsInterview = [bool]$NeedsInterview FromPhase = $FromPhase SkipPhaseIds = $skipPhaseIds PermissionMode = $permissionMode } & "$PSScriptRoot\modules\ProcessTypes\Invoke-KickstartProcess.ps1" -Context $ctx } # --- Prompt-based types: planning, commit, task-creation --- elseif ($Type -in @('planning', 'commit', 'task-creation')) { $ctx = @{ Type = $Type BotRoot = $botRoot ProcId = $procId ProcessData = $processData ModelName = $claudeModelName SessionId = $claudeSessionId Prompt = $Prompt Description = $Description ShowDebug = [bool]$ShowDebug ShowVerbose = [bool]$ShowVerbose PermissionMode = $permissionMode } & "$PSScriptRoot\modules\ProcessTypes\Invoke-PromptProcess.ps1" -Context $ctx } # Cleanup env vars Remove-ProcessLock -LockType $lockKey $env:DOTBOT_PROCESS_ID = $null $env:DOTBOT_CURRENT_TASK_ID = $null $env:DOTBOT_CURRENT_PHASE = $null # Output process ID for caller to use Write-BotLog -Level Debug -Message "" try { Write-Status "Process $procId finished with status: $($processData.status)" -Type Info } catch { Write-BotLog -Level Info -Message "Process $procId finished with status: $($processData.status)" } # 5-second countdown before window closes Write-BotLog -Level Debug -Message "" for ($i = 5; $i -ge 1; $i--) { Write-BotLog -Level Info -Message " Window closing in ${i}s..." Start-Sleep -Seconds 1 } Write-BotLog -Level Debug -Message "" |