From 545e1b8b49e97c7ca6f1ffc968bc3723643bda82 Mon Sep 17 00:00:00 2001 From: Enmanuel Jimenez <34482837+EnmaJim@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:32:53 -0400 Subject: [PATCH] fix(cli): auto-start the runtime for non-watch run and tasks run (#682) The runtime precondition and the auto-start were both nested inside the --watch branch, so the default path spawned the detached task runner with no runtime and exited 0. The runner then parked every task in needs-input with a runtime-endpoint-not-available note -- in a log file the caller never sees, and only after an integration branch had been created and pushed. --no-auto-runtime was bound but unreachable without --watch; it now refuses synchronously with exit 1, before Initialize-WorkflowRun, so a refused run leaves nothing behind. Start-DotbotRuntimeDetached brings the runtime up in a process of its own by spawning dotbot serve and waiting for the connection file, which Start-DotbotRuntime writes only once the listener is accepting. Start-DotbotRuntime hosts the listener in-process and records its own PID, so it cannot serve a CLI that exits immediately after spawning a detached runner. The result carries no listener handle, so a caller can never tear down a runtime it does not own. A runtime auto-started this way deliberately outlives the command; --watch still hosts its own in-process and tears it down on exit. dotbot tasks run takes the same path, and its exit code now propagates through bin/dotbot.ps1 -- exit inside an ampersand-invoked script only ends that script, so every tasks run failure previously reported success to the shell. --poll-interval-ms also consumed the token that followed it even when that token was another flag, so --poll-interval-ms --watch silently dropped --watch and took the very path above. --- CHANGELOG.md | 5 ++ bin/dotbot.ps1 | 8 ++- src/cli/tasks-run.ps1 | 16 +++++ src/cli/workflow-run.ps1 | 29 ++++++--- .../Dotbot.Runtime/Dotbot.Runtime.psd1 | 1 + .../Dotbot.Runtime/Private/Lifecycle.psm1 | 60 +++++++++++++++++++ tests/Test-ProcessDispatch.ps1 | 60 +++++++++++++++++++ 7 files changed, 169 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef457d92..e67c8f61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,15 @@ All notable changes to dotbot are documented in this file. The format follows [K ## [Unreleased] ### Added +- **`Start-DotbotRuntimeDetached`** in `Dotbot.Runtime` — brings the per-project runtime up in a process of its own by spawning `dotbot serve` as a child and waiting for `.control/runtime.json`, which `Start-DotbotRuntime` writes only once the listener is accepting. `Start-DotbotRuntime` hosts the listener in-process and records its own `$PID`, so it cannot serve a CLI that exits immediately after spawning a detached runner. The result carries no `listener` handle, so a caller can never tear down a runtime it does not own. ### Changed +- **`dotbot run ` and `dotbot tasks run` now start the runtime themselves** when none is alive, instead of only doing so under `--watch`. `--no-auto-runtime` is the opt-out on both paths and now refuses synchronously with exit 1. `--watch` still hosts its runtime in-process and tears it down on exit; the runtime auto-started for a detached run deliberately outlives the command (`dotbot runtime-status` reports its PID). For `dotbot run` the runtime is settled *before* `Initialize-WorkflowRun`, so a runtime that cannot start leaves no run, no tasks and no integration branch behind. +- **`dotbot tasks run` propagates its exit code** through `bin/dotbot.ps1`, matching `dotbot run` / `dotbot workflow run` / `dotbot doctor`. `exit` inside a `&`-invoked script only ends that script, so every `tasks run` failure — including the pre-existing "no .bot" and "Invoke-DotbotProcess.ps1 not found" guards — previously reported success to the shell. ### Fixed +- **`dotbot run ` without `--watch` reported success for a run that could not progress (#682).** The runtime precondition and auto-start were both nested inside `if ($Watch)`, so the default path spawned the detached task-runner with no runtime and exited 0; the runner then parked every task in `needs-input` with `Dotbot runtime endpoint not available` — asynchronously, in a log file the caller never sees — after creating and pushing an integration branch. `--no-auto-runtime` was bound but unreachable without `--watch`. +- **`--poll-interval-ms` consumed the following token even when it was another flag**, so `dotbot run --poll-interval-ms --watch` cast `[int]"--watch"`, silently dropped `--watch`, and took the very detached path above. It now uses the same `-notmatch '^--?'` guard as the parser's `default` arm. ### Removed diff --git a/bin/dotbot.ps1 b/bin/dotbot.ps1 index a8c2d7c5..fc258281 100755 --- a/bin/dotbot.ps1 +++ b/bin/dotbot.ps1 @@ -318,7 +318,7 @@ function Get-WorkflowRunInvocation { $i++ } 'pollintervalms' { - if (($i + 1) -lt $RunArgs.Count) { + if (($i + 1) -lt $RunArgs.Count -and [string]$RunArgs[$i + 1] -notmatch '^--?') { $runSplat['PollIntervalMs'] = [int]$RunArgs[$i + 1] $i += 2 } else { @@ -545,7 +545,11 @@ function Invoke-Tasks { switch ($sub) { 'run' { $script = Join-Path $ScriptsDir 'tasks-run.ps1' - if (Test-Path $script) { & $script } else { Write-DotbotError "tasks-run.ps1 not found" } + if (Test-Path $script) { + $global:LASTEXITCODE = 0 + & $script + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } else { Write-DotbotError "tasks-run.ps1 not found" } } 'stop' { $script = Join-Path $ScriptsDir 'tasks-stop.ps1' diff --git a/src/cli/tasks-run.ps1 b/src/cli/tasks-run.ps1 index 3a6f31ed..96e2d6c2 100644 --- a/src/cli/tasks-run.ps1 +++ b/src/cli/tasks-run.ps1 @@ -34,9 +34,25 @@ if (-not (Test-Path $lpPath)) { exit 1 } +Import-Module (Join-Path $DotbotBase "src/runtime/Modules/Dotbot.Runtime/Dotbot.Runtime.psd1") -Force -DisableNameChecking + Write-DotbotBanner -Title "D O T B O T" -Subtitle "Pending tasks runner" Write-Status "Launching workflow-agnostic task runner..." +if (-not (Test-RuntimeAlive -BotRoot $BotDir)) { + Write-Status "Starting headless runtime..." + try { + $runtimeStart = Start-DotbotRuntimeDetached -BotRoot $BotDir + } catch { + Write-DotbotError $_.Exception.Message + Write-DotbotCommand "Start it manually with 'dotbot serve' in another shell." + exit 1 + } + Write-Success ("Runtime ready at {0}" -f $runtimeStart.url) +} else { + Write-DotbotCommand "Using existing headless runtime." +} + $wfArgs = @( "-Type", "task-runner", "-Continue", diff --git a/src/cli/workflow-run.ps1 b/src/cli/workflow-run.ps1 index b4d94b7d..87e403d9 100644 --- a/src/cli/workflow-run.ps1 +++ b/src/cli/workflow-run.ps1 @@ -360,14 +360,27 @@ if ($tasks.Count -eq 0) { $runtimeStart = $null $runtimeStartedHere = $false -$runtimeAlive = $false -if ($Watch) { - Import-Module (Join-Path $DotbotBase "src/runtime/Modules/Dotbot.Runtime/Dotbot.Runtime.psd1") -Force -DisableNameChecking - $runtimeAlive = Test-RuntimeAlive -BotRoot $BotDir - if (-not $runtimeAlive -and $NoAutoRuntime) { - Write-DotbotError "The dotbot runtime is not running." - Write-DotbotCommand "Run 'dotbot serve' in another shell, or omit --no-auto-runtime." - exit 1 +Import-Module (Join-Path $DotbotBase "src/runtime/Modules/Dotbot.Runtime/Dotbot.Runtime.psd1") -Force -DisableNameChecking +$runtimeAlive = Test-RuntimeAlive -BotRoot $BotDir +if (-not $runtimeAlive -and $NoAutoRuntime) { + Write-DotbotError "The dotbot runtime is not running." + Write-DotbotCommand "Run 'dotbot serve' in another shell, or omit --no-auto-runtime." + exit 1 +} + +if (-not $Watch) { + if (-not $runtimeAlive) { + Write-Status "Starting headless runtime..." + try { + $runtimeStart = Start-DotbotRuntimeDetached -BotRoot $BotDir + } catch { + Write-DotbotError $_.Exception.Message + Write-DotbotCommand "Start it manually with 'dotbot serve' in another shell." + exit 1 + } + Write-Success ("Runtime ready at {0}" -f $runtimeStart.url) + } else { + Write-DotbotCommand "Using existing headless runtime." } } diff --git a/src/runtime/Modules/Dotbot.Runtime/Dotbot.Runtime.psd1 b/src/runtime/Modules/Dotbot.Runtime/Dotbot.Runtime.psd1 index a6bdc918..0b47951a 100644 --- a/src/runtime/Modules/Dotbot.Runtime/Dotbot.Runtime.psd1 +++ b/src/runtime/Modules/Dotbot.Runtime/Dotbot.Runtime.psd1 @@ -52,6 +52,7 @@ # Lifecycle 'Start-DotbotRuntime' + 'Start-DotbotRuntimeDetached' 'Stop-DotbotRuntime' 'Test-RuntimeAlive' 'New-RuntimeBearerToken' diff --git a/src/runtime/Modules/Dotbot.Runtime/Private/Lifecycle.psm1 b/src/runtime/Modules/Dotbot.Runtime/Private/Lifecycle.psm1 index 9d5556a2..f8a529b1 100644 --- a/src/runtime/Modules/Dotbot.Runtime/Private/Lifecycle.psm1 +++ b/src/runtime/Modules/Dotbot.Runtime/Private/Lifecycle.psm1 @@ -275,6 +275,65 @@ function Start-DotbotRuntime { return $result } +function Start-DotbotRuntimeDetached { + <# + .SYNOPSIS + Bring the runtime up in a process of its own so it outlives the caller. + + .DESCRIPTION + Start-DotbotRuntime hosts the listener in-process and records $PID, so a + CLI that spawns a detached runner and then exits would take the runtime + down with it. This spawns 'dotbot serve' as a child process instead — the + background mode serve.ps1 documents — and waits for the connection file, + which Start-DotbotRuntime writes only once the listener is accepting. + + Attaches instead of spawning when a runtime is already alive. + + Returns @{ url; pid; attached }. There is deliberately no 'listener' key: + the caller does not own this runtime and must never stop it. + + Throws when the runtime does not come up within TimeoutSeconds. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$BotRoot, + + [int]$TimeoutSeconds = 30 + ) + + $child = $null + if (-not (Test-RuntimeAlive -BotRoot $BotRoot)) { + $serveScript = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../../../../cli/serve.ps1')) + if (-not (Test-Path -LiteralPath $serveScript -PathType Leaf)) { + throw "Runtime host script not found at $serveScript." + } + + $child = Start-DotbotChildProcess ` + -File $serveScript ` + -WorkingDirectory (Split-Path -Parent $BotRoot) ` + -WindowStyle Hidden + + $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) + while (-not (Test-RuntimeAlive -BotRoot $BotRoot)) { + if ($child.HasExited) { + throw "The dotbot runtime host exited before the runtime was ready." + } + if ([DateTime]::UtcNow -ge $deadline) { + throw "The dotbot runtime did not become ready within $TimeoutSeconds seconds." + } + Start-Sleep -Milliseconds 250 + } + } + + $conn = Read-RuntimeConnectionFile -BotRoot $BotRoot + return [ordered]@{ + url = [string]$conn.url + pid = $conn.pid + attached = ($null -eq $child) + } +} + function Stop-DotbotRuntime { <# .SYNOPSIS @@ -311,6 +370,7 @@ function Stop-DotbotRuntime { Export-ModuleMember -Function @( 'Start-DotbotRuntime' + 'Start-DotbotRuntimeDetached' 'Stop-DotbotRuntime' 'Test-RuntimeAlive' 'New-RuntimeBearerToken' diff --git a/tests/Test-ProcessDispatch.ps1 b/tests/Test-ProcessDispatch.ps1 index e11c645d..d4e7be84 100644 --- a/tests/Test-ProcessDispatch.ps1 +++ b/tests/Test-ProcessDispatch.ps1 @@ -274,12 +274,72 @@ if (Test-Path $dispatcherScript) { Assert-True -Name "dotbot.ps1 propagates workflow-run exit codes" ` -Condition ($dispatcherCliContent -match 'LASTEXITCODE\s*=\s*0' -and $dispatcherCliContent -match 'exit \$LASTEXITCODE') ` -Message "dotbot run should return workflow-run.ps1 failures to shell callers" + + $dispatcherAst = [System.Management.Automation.Language.Parser]::ParseFile($dispatcherScript, [ref]$null, [ref]$null) + $parserFn = $dispatcherAst.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq 'Get-WorkflowRunInvocation' + }, $true) + + if ($parserFn) { + . ([scriptblock]::Create($parserFn.Extent.Text)) + + $swallowed = Get-WorkflowRunInvocation -RunArgs @('smoke-test', '--poll-interval-ms', '--watch') + Assert-True -Name "--poll-interval-ms does not swallow the flag that follows it" ` + -Condition ([bool]$swallowed.Parameters['Watch']) ` + -Message "--watch must still bind when it follows a valueless --poll-interval-ms" + Assert-Equal -Name "--poll-interval-ms without a value falls back to the default" ` + -Expected 1000 -Actual $swallowed.Parameters['PollIntervalMs'] + + $withValue = Get-WorkflowRunInvocation -RunArgs @('smoke-test', '--poll-interval-ms', '500', '--watch') + Assert-Equal -Name "--poll-interval-ms still reads its value when given one" ` + -Expected 500 -Actual $withValue.Parameters['PollIntervalMs'] + Assert-True -Name "--poll-interval-ms leaves a following --watch bound" ` + -Condition ([bool]$withValue.Parameters['Watch']) ` + -Message "Regression: the valued form must keep parsing the rest of the line" + } else { + Write-TestResult -Name "Get-WorkflowRunInvocation parser tests" -Status Skip -Message "Function not found in $dispatcherScript" + } } else { Write-TestResult -Name "dotbot.ps1 workflow run dispatch" -Status Skip -Message "Script not found at $dispatcherScript" } Write-Host "" +Write-Host " CLI RUNTIME PRECONDITION" -ForegroundColor Cyan +Write-Host " --------------------------------------------" -ForegroundColor DarkGray + +$precondProject = $null +try { + $precondProject = New-TestProjectFromGolden -Flavor 'start-from-prompt' -Prefix 'dotbot-test-runtime-precond' + + Push-Location $precondProject.ProjectRoot + try { + $precondOut = & pwsh -NoProfile -ExecutionPolicy Bypass -File $dispatcherScript run smoke-test --no-auto-runtime 2>&1 + $precondCode = $LASTEXITCODE + } finally { + Pop-Location + } + $precondOut = $precondOut | Out-String + + Assert-Equal -Name "dotbot run --no-auto-runtime exits non-zero without --watch when no runtime is up" ` + -Expected 1 -Actual $precondCode -Message $precondOut + + Assert-True -Name "dotbot run --no-auto-runtime says the runtime is not running" ` + -Condition ($precondOut -match 'runtime is not running') ` + -Message $precondOut + + $mintedRuns = @(Get-ChildItem -LiteralPath (Join-Path $precondProject.BotDir 'workspace/tasks/workflow-runs') -Directory -ErrorAction SilentlyContinue) + Assert-Equal -Name "dotbot run --no-auto-runtime mints no WorkflowRun" ` + -Expected 0 -Actual $mintedRuns.Count ` + -Message "The runtime check must run before Initialize-WorkflowRun so a refused run leaves no tasks or branches behind" +} finally { + if ($precondProject) { Remove-TestProject -Path $precondProject.ProjectRoot } +} + +Write-Host "" + # =================================================================== # SUMMARY # ===================================================================