You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
308 lines
13 KiB
308 lines
13 KiB
<#
|
|
.SYNOPSIS
|
|
Launch a nightshift overnight run, detached, with a guaranteed finalize step.
|
|
.DESCRIPTION
|
|
Phase "Launch" (default) runs preflight, records the run in the durable registry, and spawns a
|
|
detached worker. It returns quickly and prints a JSON object for Scout to read.
|
|
|
|
Phase "Worker" is internal. It runs the engine to completion, re-runs verification, and writes
|
|
the Obsidian vault record. It takes only a run id -- every other value is read back out of
|
|
runs.json, which avoids quoting a multi-line prompt through a process boundary.
|
|
|
|
The finalize work lives here, in the detached process, on purpose: the Scout session that
|
|
launched the run is usually gone by the time the engine exits, so an agent-side "on exit" hook
|
|
would silently never fire.
|
|
.EXAMPLE
|
|
.\start-run.ps1 -RepoPath D:\Repos\myapp -Prompt "reduce complexity in src/utils" `
|
|
-StopWhen "npm test passes and no unrelated files changed" -VerifyCommand "npm test"
|
|
#>
|
|
[CmdletBinding()]
|
|
param(
|
|
[ValidateSet('Launch', 'Worker')]
|
|
[string]$Phase = 'Launch',
|
|
|
|
[string]$RunStateId,
|
|
|
|
[string]$RepoPath,
|
|
[string]$Prompt,
|
|
[string]$Title,
|
|
[string]$Agent = 'copilot',
|
|
[string]$Model,
|
|
[string]$StopWhen,
|
|
[string]$VerifyCommand,
|
|
[int]$MaxIterations = 40,
|
|
[long]$MaxTokens = 0,
|
|
[ValidateSet('hands-off', 'companion')]
|
|
[string]$Mode = 'hands-off',
|
|
[switch]$Worktree,
|
|
[switch]$CurrentBranch,
|
|
[switch]$Push,
|
|
[switch]$Mock,
|
|
[switch]$SkipVerifyPreflight
|
|
)
|
|
|
|
Set-StrictMode -Version Latest
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
. (Join-Path $PSScriptRoot 'lib.ps1')
|
|
$paths = Get-LightsOutPaths
|
|
|
|
function Get-AgentCommandName {
|
|
param([string]$AgentName)
|
|
switch -Regex ($AgentName) {
|
|
'^acp:' { return $null }
|
|
'^cursor$' { return 'cursor-agent' }
|
|
default { return $AgentName }
|
|
}
|
|
}
|
|
|
|
function Invoke-VerifyCommand {
|
|
param([string]$Command, [string]$WorkingDir, [string]$LogPath)
|
|
Push-Location -LiteralPath $WorkingDir
|
|
try {
|
|
if ($LogPath) {
|
|
& pwsh -NoProfile -NonInteractive -Command $Command *> $LogPath
|
|
}
|
|
else {
|
|
& pwsh -NoProfile -NonInteractive -Command $Command *> $null
|
|
}
|
|
return $LASTEXITCODE
|
|
}
|
|
finally { Pop-Location }
|
|
}
|
|
|
|
if ($Phase -eq 'Worker') {
|
|
$run = Get-LightsOutRun -Id $RunStateId
|
|
if (-not $run) { throw "No run registered with id '$RunStateId'." }
|
|
$run | Add-Member -NotePropertyName workerPid -NotePropertyValue $PID -Force
|
|
$run | Add-Member -NotePropertyName status -NotePropertyValue 'running' -Force
|
|
Save-LightsOutRun -Run $run
|
|
|
|
$logDir = Join-Path $paths.Root 'logs'
|
|
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
|
|
$consoleLogStem = if ($run.PSObject.Properties.Name -contains 'registryId' -and $run.registryId) { $run.registryId } else { $run.id }
|
|
$consoleLog = Join-Path $logDir "$consoleLogStem.console.log"
|
|
|
|
$engineArgs = @()
|
|
if ($run.agent) { $engineArgs += @('--agent', $run.agent) }
|
|
if ($run.PSObject.Properties['model'] -and $run.model) { $engineArgs += @('--model', $run.model) }
|
|
if ($run.PSObject.Properties['stopWhen'] -and $run.stopWhen) { $engineArgs += @('--stop-when', $run.stopWhen) }
|
|
if ($run.PSObject.Properties['maxIterations'] -and $run.maxIterations -gt 0) { $engineArgs += @('--max-iterations', "$($run.maxIterations)") }
|
|
if ($run.PSObject.Properties['maxTokens'] -and $run.maxTokens -gt 0) { $engineArgs += @('--max-tokens', "$($run.maxTokens)") }
|
|
if ($run.PSObject.Properties['worktree'] -and $run.worktree) { $engineArgs += '--worktree' }
|
|
if ($run.PSObject.Properties['currentBranch'] -and $run.currentBranch) { $engineArgs += '--current-branch' }
|
|
if ($run.PSObject.Properties['push'] -and $run.push) { $engineArgs += '--push' }
|
|
if ($run.PSObject.Properties['mock'] -and $run.mock) { $engineArgs += '--mock' }
|
|
$engineArgs += @('--prevent-sleep', 'on', '--meteor-frequency', '0', $run.prompt)
|
|
|
|
$engineCommand = Resolve-NightshiftCommand
|
|
if (-not $engineCommand) {
|
|
$run | Add-Member -NotePropertyName status -NotePropertyValue 'failed' -Force
|
|
$run | Add-Member -NotePropertyName endedAt -NotePropertyValue (Get-Date).ToString('o') -Force
|
|
$run | Add-Member -NotePropertyName launchError -NotePropertyValue 'Nightshift could not be resolved (no override, no vendored copy, not on PATH).' -Force
|
|
Save-LightsOutRun -Run $run
|
|
exit 1
|
|
}
|
|
$invokeArgs = @($engineCommand.Prefix) + $engineArgs
|
|
|
|
$env:NO_COLOR = '1'
|
|
$env:TERM = 'dumb'
|
|
|
|
$exitCode = -1
|
|
try {
|
|
Push-Location -LiteralPath $run.repoPath
|
|
try {
|
|
& $engineCommand.File @invokeArgs *> $consoleLog
|
|
$exitCode = $LASTEXITCODE
|
|
}
|
|
finally { Pop-Location }
|
|
}
|
|
catch {
|
|
$exitCode = -1
|
|
Add-Content -LiteralPath $consoleLog -Value "`n[lights-out] launcher exception: $($_.Exception.Message)"
|
|
}
|
|
|
|
$run | Add-Member -NotePropertyName endedAt -NotePropertyValue (Get-Date).ToString('o') -Force
|
|
$run | Add-Member -NotePropertyName engineExitCode -NotePropertyValue $exitCode -Force
|
|
$run | Add-Member -NotePropertyName status -NotePropertyValue 'finalizing' -Force
|
|
Save-LightsOutRun -Run $run
|
|
|
|
$runDir = Resolve-LightsOutRunDirFromConsoleLog -ConsoleLogPath $consoleLog
|
|
$checkoutPath = if ($runDir) { Get-LightsOutCheckoutPathFromRunDir -RunDir $runDir } else { Resolve-LightsOutCheckoutPath -Run $run }
|
|
if ($checkoutPath) { $run | Add-Member -NotePropertyName checkoutPath -NotePropertyValue $checkoutPath -Force }
|
|
|
|
if (-not $runDir) {
|
|
$runDir = if ($checkoutPath) { Resolve-LightsOutRunDir -RepoPath $checkoutPath -RunId $run.id } else { $null }
|
|
}
|
|
if ($runDir) {
|
|
$actualRunId = Split-Path -Leaf $runDir
|
|
$run | Add-Member -NotePropertyName launchId -NotePropertyValue $(if ($run.PSObject.Properties.Name -contains 'launchId') { $run.launchId } else { $run.id }) -Force
|
|
$run | Add-Member -NotePropertyName engineRunId -NotePropertyValue $actualRunId -Force
|
|
$run | Add-Member -NotePropertyName id -NotePropertyValue $actualRunId -Force
|
|
$run | Add-Member -NotePropertyName runDir -NotePropertyValue $runDir -Force
|
|
}
|
|
if ($checkoutPath -and (Test-Path -LiteralPath $checkoutPath)) {
|
|
try {
|
|
$actualBranch = (git -C $checkoutPath rev-parse --abbrev-ref HEAD 2>$null)
|
|
if ($actualBranch) { $run | Add-Member -NotePropertyName branch -NotePropertyValue $actualBranch -Force }
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
if ($run.PSObject.Properties['verifyCommand'] -and $run.verifyCommand) {
|
|
$verifyLog = Join-Path $logDir "$($run.id).verify-after.log"
|
|
$verifyDir = if ($checkoutPath) { $checkoutPath } elseif (-not ($run.PSObject.Properties['worktree'] -and $run.worktree)) { $run.repoPath } else { $null }
|
|
if ($verifyDir) {
|
|
try {
|
|
$after = Invoke-VerifyCommand -Command $run.verifyCommand -WorkingDir $verifyDir -LogPath $verifyLog
|
|
$run | Add-Member -NotePropertyName verifyExitCodeAfter -NotePropertyValue $after -Force
|
|
}
|
|
catch {
|
|
$run | Add-Member -NotePropertyName verifyExitCodeAfter -NotePropertyValue 'error' -Force
|
|
}
|
|
}
|
|
else {
|
|
$run | Add-Member -NotePropertyName verifyExitCodeAfter -NotePropertyValue 'skipped-unresolved-checkout' -Force
|
|
}
|
|
}
|
|
|
|
$finalStatus = if ($exitCode -eq 0) { 'completed' } else { 'failed' }
|
|
$run | Add-Member -NotePropertyName status -NotePropertyValue $finalStatus -Force
|
|
|
|
try {
|
|
$notePath = Write-LightsOutVaultNote -Run $run -RunDir $runDir -ConsoleLogPath $consoleLog
|
|
$run | Add-Member -NotePropertyName vaultNote -NotePropertyValue $notePath -Force
|
|
}
|
|
catch {
|
|
$run | Add-Member -NotePropertyName vaultNoteError -NotePropertyValue $_.Exception.Message -Force
|
|
}
|
|
|
|
Save-LightsOutRun -Run $run
|
|
exit 0
|
|
}
|
|
|
|
if (-not $RepoPath) { throw 'RepoPath is required.' }
|
|
if ([string]::IsNullOrWhiteSpace($Prompt)) {
|
|
throw 'Prompt is required and must be non-empty. An empty prompt makes a detached nightshift run hang on stdin.'
|
|
}
|
|
|
|
$RepoPath = (Resolve-Path -LiteralPath $RepoPath).Path
|
|
$problems = @()
|
|
|
|
$engineCommand = Resolve-NightshiftCommand
|
|
if (-not $engineCommand) {
|
|
$problems += 'Nightshift could not be resolved. Restore the complete vendored runtime from a working Lights Out copy, or use the separately stored maintenance archives described in SKILL.md. Do not install an unrelated published package.'
|
|
}
|
|
elseif ($engineCommand.Kind -eq 'vendored' -and -not (Get-Command node -ErrorAction SilentlyContinue)) {
|
|
$problems += 'The vendored nightshift engine needs node.exe on PATH, and it was not found.'
|
|
}
|
|
|
|
$insideRepo = (git -C $RepoPath rev-parse --is-inside-work-tree 2>$null)
|
|
if ($insideRepo -ne 'true') { $problems += "$RepoPath is not a git repository. Run 'git init' first." }
|
|
|
|
$branch = $null
|
|
$baseCommit = $null
|
|
if ($insideRepo -eq 'true') {
|
|
$dirty = @(git -C $RepoPath status --porcelain 2>$null)
|
|
if ($dirty.Count -gt 0) {
|
|
$problems += "Working tree is dirty ($($dirty.Count) change(s)). Nightshift requires a clean tree. Commit or stash first."
|
|
}
|
|
|
|
$branch = (git -C $RepoPath rev-parse --abbrev-ref HEAD 2>$null)
|
|
if ($branch -and $branch.StartsWith('nightshift/') -and -not $CurrentBranch) {
|
|
$problems += "Currently on nightshift branch '$branch'. A detached run cannot answer the overwrite prompt. Switch to the base branch first."
|
|
}
|
|
$baseCommit = (git -C $RepoPath rev-parse HEAD 2>$null)
|
|
}
|
|
|
|
$agentCommand = if ($Mock) { $null } else { Get-AgentCommandName -AgentName $Agent }
|
|
if ($agentCommand -and -not (Get-Command $agentCommand -ErrorAction SilentlyContinue)) {
|
|
$problems += "Agent CLI '$agentCommand' (for --agent $Agent) is not on PATH. Install and authenticate it before an unattended run."
|
|
}
|
|
|
|
$verifyBefore = $null
|
|
if ($VerifyCommand -and -not $SkipVerifyPreflight) {
|
|
$firstToken = ($VerifyCommand.Trim() -split '\s+')[0]
|
|
if (-not (Get-Command $firstToken -ErrorAction SilentlyContinue)) {
|
|
$problems += "Verification command '$VerifyCommand' cannot run: '$firstToken' is not on PATH. Pick a command that exists on this machine."
|
|
}
|
|
elseif ($problems.Count -eq 0) {
|
|
$logDir = Join-Path $paths.Root 'logs'
|
|
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
|
|
$verifyBefore = Invoke-VerifyCommand -Command $VerifyCommand -WorkingDir $RepoPath -LogPath (Join-Path $logDir 'verify-before.log')
|
|
}
|
|
}
|
|
|
|
if ($problems.Count -gt 0) {
|
|
[pscustomobject]@{
|
|
ok = $false
|
|
problems = $problems
|
|
} | ConvertTo-Json -Depth 5
|
|
exit 1
|
|
}
|
|
|
|
$runId = Get-LightsOutRunId -Prompt $Prompt
|
|
$repoName = Split-Path -Leaf $RepoPath
|
|
if (-not $Title) {
|
|
$Title = ($Prompt -split "`n" | Where-Object { $_.Trim() } | Select-Object -First 1)
|
|
}
|
|
|
|
$run = [pscustomobject]@{
|
|
registryId = [guid]::NewGuid().ToString('n')
|
|
id = $runId
|
|
launchId = $runId
|
|
schemaVersion = 1
|
|
status = 'launching'
|
|
repoPath = $RepoPath
|
|
repoName = $repoName
|
|
branch = if ($CurrentBranch) { $branch } else { "nightshift/$runId" }
|
|
baseCommit = $baseCommit
|
|
prompt = $Prompt
|
|
title = (Get-LightsOutSafeName -Text $Title -MaxLength 60)
|
|
agent = $Agent
|
|
model = $Model
|
|
mode = $Mode
|
|
stopWhen = $StopWhen
|
|
maxIterations = $MaxIterations
|
|
maxTokens = $MaxTokens
|
|
worktree = [bool]$Worktree
|
|
currentBranch = [bool]$CurrentBranch
|
|
push = [bool]$Push
|
|
mock = [bool]$Mock
|
|
verifyCommand = $VerifyCommand
|
|
verifyExitCodeBefore = $verifyBefore
|
|
engineKind = $engineCommand.Kind
|
|
engineSource = $engineCommand.Source
|
|
engineVersion = $engineCommand.Version
|
|
startedAt = (Get-Date).ToString('o')
|
|
launchedBy = 'scout:lights-out'
|
|
workerPid = $null
|
|
}
|
|
Save-LightsOutRun -Run $run
|
|
|
|
$workerArgs = @(
|
|
'-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
|
|
'-File', "`"$PSCommandPath`"",
|
|
'-Phase', 'Worker',
|
|
'-RunStateId', $run.registryId
|
|
)
|
|
$process = Start-Process -FilePath (Get-Process -Id $PID).Path `
|
|
-ArgumentList $workerArgs -WindowStyle Hidden -PassThru
|
|
|
|
[pscustomobject]@{
|
|
ok = $true
|
|
id = $runId
|
|
registryId = $run.registryId
|
|
launchId = $run.launchId
|
|
branch = $run.branch
|
|
repoPath = $RepoPath
|
|
agent = $Agent
|
|
mode = $Mode
|
|
workerPid = $process.Id
|
|
stopWhen = $StopWhen
|
|
verifyCommand = $VerifyCommand
|
|
verifyBefore = $verifyBefore
|
|
engine = "$($engineCommand.Kind): $($engineCommand.Source)$(if ($engineCommand.Version) { " (v$($engineCommand.Version))" })"
|
|
statePath = $paths.StatePath
|
|
expectedVault = (Get-LightsOutVaultFolder -Run $run)
|
|
} | ConvertTo-Json -Depth 5
|
|
|