Public/Resume-AITDebate.ps1
|
# Copyright (c) 2026 Jeffrey Snover. All rights reserved. # Licensed under the MIT License. See LICENSE file in the project root. function Resume-AITDebate { <# .SYNOPSIS Finishes a debate that was interrupted mid-finalization from its round checkpoint, running only the missing tail. .DESCRIPTION Background: a 44-minute expensive debate (exp-brief-cite-flash, 2026-06-29) was rendered unrecoverable when the host slept during finalization. t/1135 made debates crash-recoverable by retaining the `-partial.json` checkpoint until the final session JSON is durable + adding a `DebateEngine.resume()` static entry point. This cmdlet wraps that engine path: load the partial, run synthesis + post-synthesis passes only, persist the full session, return the normal Invoke-AITDebate result shape. Idempotent: if the partial already contains a synthesis transcript entry, the engine skips re-running synthesis and just re-persists. .PARAMETER From Path to the `-partial.json` checkpoint left behind by an interrupted debate. The file must contain the structured fields required by the engine's resume contract (transcript, argument_network, crux_tracker); an ActionableError is raised otherwise. .PARAMETER OutputDirectory Where to write the resumed session artifacts. Defaults to the directory containing the partial. .PARAMETER ProgressFile Optional. When set, this resumed run reports per-turn status to a shared progress file (so Watch-DebateProgress can see it). .PARAMETER ProgressDebateName Optional. Debate name to use in the progress file. Defaults to the partial's base filename (minus `-partial.json`). .EXAMPLE Resume-AITDebate -From ./debates/cli-runs/my-debate-partial.json .EXAMPLE # Run measurement on the resumed result $r = Resume-AITDebate -From <partial> $r | Measure-DebateQuality .LINK Show-AITriadHelp .LINK Show-TriadDialogue .LINK Invoke-AITDebate .LINK Get-AITDebate .LINK Repair-DebateOutput .LINK Watch-DebateProgress .LINK Invoke-DebateBatch #> [CmdletBinding()] param( [Parameter(Mandatory, Position = 0)] [ValidateScript({ Test-Path $_ })] [Alias('Path', 'Checkpoint')] [string]$From, [Parameter()] [Alias('OutputPath')] [string]$OutputDirectory, [Parameter()] [string]$ProgressFile, [Parameter()] [string]$ProgressDebateName ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # ── Validate checkpoint contract ────────────────────────── $CheckpointPath = (Resolve-Path $From).Path try { $Checkpoint = Get-Content -Raw -Path $CheckpointPath | ConvertFrom-Json -ErrorAction Stop } catch { New-ActionableError ` -Goal 'Resume debate from checkpoint' ` -Problem "Failed to parse checkpoint as JSON: $($_.Exception.Message)" ` -Location 'Resume-AITDebate' ` -NextSteps @( "Verify the file is a valid debate -partial.json checkpoint", "Path: $CheckpointPath" ) -Throw } $MissingFields = [System.Collections.Generic.List[string]]::new() foreach ($field in 'transcript', 'argument_network', 'crux_tracker') { if (-not $Checkpoint.PSObject.Properties[$field]) { $MissingFields.Add($field) } } if (@($MissingFields).Count -gt 0) { New-ActionableError ` -Goal 'Resume debate from checkpoint' ` -Problem "Checkpoint missing required structured fields: $($MissingFields -join ', ')" ` -Location 'Resume-AITDebate' ` -NextSteps @( "The resume contract (CL spec, t/1135) requires transcript + argument_network + crux_tracker", 'Older checkpoints from before t/1135 are not resumable; the debate must be re-run from scratch', "Path: $CheckpointPath" ) -Throw } # ── Verify npx is available ────────────────────────────── $NpxCmd = Get-Command npx.cmd -ErrorAction SilentlyContinue if (-not $NpxCmd) { $NpxCmd = Get-Command npx -ErrorAction SilentlyContinue } if (-not $NpxCmd) { New-ActionableError ` -Goal 'Resume debate from checkpoint' ` -Problem 'npx (Node.js package runner) is not installed' ` -Location 'Resume-AITDebate' ` -NextSteps @('Install Node.js from https://nodejs.org (v18+), then verify: npx --version') ` -Throw } # ── Resolve output directory + progress wiring ─────────── if (-not $OutputDirectory) { $OutputDirectory = Split-Path -Parent $CheckpointPath } if (-not (Test-Path $OutputDirectory)) { $null = New-Item -ItemType Directory -Path $OutputDirectory -Force } $ResolvedOutputDir = (Resolve-Path $OutputDirectory).Path $EffectiveDebateName = if ($ProgressDebateName) { $ProgressDebateName } else { [System.IO.Path]::GetFileNameWithoutExtension($CheckpointPath) -replace '-partial$', '' } $ProgressActive = -not [string]::IsNullOrWhiteSpace($ProgressFile) if ($ProgressActive) { Update-DebateProgress -Path $ProgressFile -DebateName $EffectiveDebateName -Fields @{ status = 'running' started_at = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') current_stage = 'resuming' } } # ── Locate the CLI ─────────────────────────────────────── $RepoRoot = Get-CodeRoot $CliPath = Join-Path (Join-Path (Join-Path $RepoRoot 'lib') 'debate') 'cli.ts' if (-not (Test-Path $CliPath)) { New-ActionableError ` -Goal 'Resume debate from checkpoint' ` -Problem "Debate CLI not found at: $CliPath" ` -Location 'Resume-AITDebate' ` -NextSteps @('Verify lib/debate/cli.ts exists in the repo', "Repo root: $RepoRoot") ` -Throw } Write-Verbose "Resuming from: $CheckpointPath" Write-Verbose "Output dir: $ResolvedOutputDir" Write-Verbose "CLI: $CliPath" # ── Spawn npx tsx <cli> --resume <partial> ─────────────── $StdOut = [System.Collections.Generic.List[string]]::new() $StdErr = [System.Collections.Generic.List[string]]::new() $Psi = [System.Diagnostics.ProcessStartInfo]::new() $Psi.FileName = $NpxCmd.Source $Psi.Arguments = "tsx `"$CliPath`" --resume `"$CheckpointPath`"" $Psi.WorkingDirectory = $RepoRoot $Psi.RedirectStandardOutput = $true $Psi.RedirectStandardError = $true $Psi.UseShellExecute = $false $Psi.CreateNoWindow = $true try { $Proc = [System.Diagnostics.Process]::Start($Psi) } catch { New-ActionableError ` -Goal 'Resume debate from checkpoint' ` -Problem "Failed to start debate CLI process (npx tsx): $($_.Exception.Message)" ` -Location 'Resume-AITDebate' ` -NextSteps @('Verify Node.js is installed and npx is in PATH: npx --version') ` -Throw } # t/1170: drain stdout asynchronously so the OS pipe buffer never fills # while we're reading stderr line-by-line (classic .NET Process stdout/stderr # deadlock fix — multi-MB session JSON on stdout would otherwise block the # CLI write and keep stderr from EOF-ing). $StdOutTask = $Proc.StandardOutput.ReadToEndAsync() try { # Stream stderr for progress + Verbose $CurrentTurn = 0 while (-not $Proc.StandardError.EndOfStream) { $Line = $Proc.StandardError.ReadLine() if (-not $Line) { continue } $StdErr.Add($Line) if ($Line -match '\[debate-cli\]\s*\[(\w+)\]\s*(\w+)?:?\s*(.*)') { $Phase = $Matches[1]; $Speaker = $Matches[2]; $Message = $Matches[3].Trim() if ($Speaker -and $Message) { $Color = switch ($Speaker.ToLower()) { 'accelerationist' { 'Green' } 'safetyist' { 'Red' } 'skeptic' { 'Yellow' } default { 'DarkGray' } } Write-Host " [$Phase] $Speaker`: $Message" -ForegroundColor $Color if ($ProgressActive) { $CurrentTurn++ Update-DebateProgress -Path $ProgressFile -DebateName $EffectiveDebateName -Fields @{ status = 'running' current_turn = $CurrentTurn current_stage = $Phase current_debater = $Speaker } } } else { Write-Host " $Line" -ForegroundColor DarkGray } } elseif ($Line -match '\[debate-cli\]\s*(.+)') { Write-Verbose "CLI: $($Matches[1])" } else { Write-Host $Line -ForegroundColor DarkGray } } # t/1170: await the async stdout drain kicked off after Process.Start. $StdOutText = $StdOutTask.GetAwaiter().GetResult() if (-not $Proc.WaitForExit(600000)) { try { $Proc.Kill() } catch { } throw "Debate CLI process timed out after 10 minutes during resume. Stderr tail:`n$(($StdErr | Select-Object -Last 20) -join "`n")" } if ($StdOutText) { $StdOut.Add($StdOutText) } $ResultJson = $StdOut -join "`n" if ($Proc.ExitCode -ne 0) { $StderrTail = (@($StdErr) | Select-Object -Last 20) -join "`n" $StdoutPreview = if ($ResultJson) { "Stdout preview (first 500 chars):`n$($ResultJson.Substring(0, [Math]::Min(500, $ResultJson.Length)))" } else { '(stdout was empty)' } throw @" Debate CLI resume failed with exit code $($Proc.ExitCode). Stderr (last 20 lines): $StderrTail $StdoutPreview "@ } if (-not $ResultJson) { throw "Debate CLI produced no output during resume (exit $($Proc.ExitCode)). Stderr tail:`n$(($StdErr | Select-Object -Last 20) -join "`n")" } try { $Result = $ResultJson | ConvertFrom-Json -ErrorAction Stop } catch { throw "Failed to parse debate CLI response as JSON during resume: $($_.Exception.Message). First 300 chars: $($ResultJson.Substring(0, [Math]::Min(300, $ResultJson.Length)))" } if (-not $Result.success) { throw "Resume reported failure: $($Result.error)" } if ($ProgressActive) { Update-DebateProgress -Path $ProgressFile -DebateName $EffectiveDebateName -Fields @{ status = 'done' } } Write-Verbose "Resume complete: $($Result.stats.rounds) rounds (existing), $($Result.stats.entries) entries" Write-Verbose " Files: debate=$($Result.files.debate)" [PSCustomObject]@{ DebateId = $Result.debateId Name = $Result.name Slug = $Result.slug Topic = $Result.topic DebateFile = $Result.files.debate TranscriptFile = $Result.files.transcript DiagnosticsFile = $Result.files.diagnostics HarvestFile = $Result.files.harvest MarkdownFile = $Result.files.markdown Rounds = $Result.stats.rounds Entries = $Result.stats.entries ApiCalls = $Result.stats.apiCalls TotalTimeMs = $Result.stats.totalTimeMs ClaimsAccepted = $Result.stats.claimsAccepted ClaimsRejected = $Result.stats.claimsRejected SessionPath = $Result.files.debate Resumed = $true Success = $true } } catch { if ($ProgressActive) { Update-DebateProgress -Path $ProgressFile -DebateName $EffectiveDebateName -Fields @{ status = 'failed' error = $_.Exception.Message } } throw } } |