wip(0.10.2): ci-gate-1

This commit is contained in:
CodeWhale Bot committed 2026-10-07 16:53:22 -07:00
1 parent 3ae9013dd4
commit 30b07f278d
4 files changed
+252 -29

No files matched your search

+4 -1
View File
@@ -1001,7 +1001,10 @@ jobs:
RUSTC_WRAPPER: ${{ matrix.os != 'windows-latest' && env.RUSTC_WRAPPER || '' }}
- name: Windows Terminal clipboard acceptance
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'windows-latest'
timeout-minutes: 5
# The script bounds itself: every desktop stage has a 45 s deadline and
# reports its own failure with a desktop capture. This limit only has
# to stay above the sum of those stages (about 10 minutes worst case).
timeout-minutes: 12
shell: pwsh
run: node scripts/release/windows-terminal-clipboard.mjs target/debug/codewhale.exe "${{ runner.temp }}/windows-clipboard"
- name: Retain Windows Terminal clipboard evidence
+4 -1
View File
@@ -272,7 +272,10 @@ jobs:
"${bin_dir}/${{ matrix.shim_binary }}" --version
- name: Windows Terminal clipboard acceptance
if: matrix.platform == 'windows-x64'
timeout-minutes: 5
# The script bounds itself: every desktop stage has a 45 s deadline and
# reports its own failure with a desktop capture. This limit only has
# to stay above the sum of those stages (about 10 minutes worst case).
timeout-minutes: 12
shell: pwsh
run: node scripts/release/windows-terminal-clipboard.mjs "target/${{ matrix.target }}/dist/codewhale.exe" "${{ runner.temp }}/windows-clipboard"
- name: Retain Windows Terminal clipboard evidence
+41 -9
View File
@@ -27,7 +27,18 @@ const model = 'gpt-4o-mini';
const expected = Array.from({ length: 24 }, (_, n) => `cw-clipboard-${String(n + 1).padStart(2, '0')} 漢字 🐳 line ${n + 1}`).join('\n');
const requests = [];
const receipt = { source: process.env.GITHUB_SHA, binary, result: 'failed', lineCount: 24, provider: 'loopback fixture', title };
// One budget per desktop stage, shared with windows-terminal-clipboard.ps1.
// The deadlines below are backstops for a desktop script that is stuck and
// cannot report: each is the sum of the stages it covers plus slack, so a
// stage that runs out always fails first, with its own name and screenshot.
const stageSeconds = 45;
// Terminal window, focus, composer, focus; slack for PowerShell start-up.
const pasteBackstop = (4 * stageSeconds + 60) * 1000;
// Submit marker seen, focus, rendered reply.
const completedBackstop = (3 * stageSeconds + 30) * 1000;
const script = fileURLToPath(new URL('./windows-terminal-clipboard.ps1', import.meta.url));
let desktop;
let desktopEnv;
let desktopExit;
let desktopError = '';
const server = http.createServer(async (req, res) => {
@@ -66,17 +77,37 @@ const server = http.createServer(async (req, res) => {
} catch (error) { desktopError ||= String(error); res.writeHead(500); res.end(); }
});
async function until(check, label, milliseconds = 60_000) {
const desktopStopped = () => Boolean(desktop) && (desktop.exitCode !== null || desktop.signalCode !== null);
async function until(check, label, milliseconds) {
const deadline = Date.now() + milliseconds;
while (Date.now() < deadline) {
const result = await check();
if (result) return result;
assert.equal(desktop?.exitCode, null, `desktop stopped while waiting for ${label}: ${desktopError}`);
if (desktopStopped()) {
await desktopExit; // resolves on 'close', after stderr has been read in full
throw new Error(`desktop stopped (exit ${desktop.exitCode}) while waiting for ${label}: ${desktopError.trim()}`);
}
await delay(100);
}
throw new Error(`deadline waiting for ${label}: ${desktopError}`);
throw new Error(`backstop deadline (${milliseconds / 1000}s) waiting for ${label}; the desktop script was still running: ${desktopError.trim()}`);
}
const exists = async name => fs.access(path.join(evidence, name)).then(() => true, () => false);
const readEvidence = async name => (await fs.readFile(path.join(evidence, name), 'utf8')).replace(/^\uFEFF/, '');
// Runs before the desktop script is stopped, so a stuck desktop is captured
// as it is. Nothing here can turn a failure into a pass.
async function diagnose() {
try {
receipt.desktopStages = (await readEvidence('stages.log')).trim().split(/\r?\n/);
receipt.desktopStage = receipt.desktopStages.at(-1);
} catch { receipt.desktopStage = 'none reported'; }
try { receipt.desktopFailure = JSON.parse(await readEvidence('failure.json')); } catch { /* the script did not get to report */ }
if (desktopStopped() && await exists('failure-desktop.png')) return;
try {
execFileSync('powershell.exe', ['-NoProfile', '-STA', '-File', script, '-Evidence', evidence, '-Title', title, '-DiagnoseOnly', 'backstop'],
{ env: desktopEnv, timeout: 60_000, stdio: 'ignore' });
receipt.backstopDiagnostics = 'backstop-desktop.png, backstop-windows.json';
} catch (error) { receipt.backstopDiagnostics = `not captured: ${error}`; }
}
try {
receipt.binarySha256 = createHash('sha256').update(await fs.readFile(binary)).digest('hex');
receipt.version = execFileSync(binary, ['--version'], { encoding: 'utf8' }).trim();
@@ -120,16 +151,16 @@ try {
CODEWHALE_TELEMETRY: '0', CODEWHALE_NO_UPDATE_CHECK: '1', CODEWHALE_DISABLE_MODELS_DEV_FETCH: '1', CODEWHALE_DISABLE_LOCAL_OLLAMA_PROBE: '1', NO_ANIMATIONS: '1',
});
receipt.terminal = { version: '1.25.2733.0', sha256: terminalHash, distribution: 'official portable x64' };
const script = fileURLToPath(new URL('./windows-terminal-clipboard.ps1', import.meta.url));
desktop = spawn('powershell.exe', ['-NoProfile', '-STA', '-File', script, terminal, binary, workspace, baseUrl, title, evidence], { env, windowsHide: false, stdio: ['ignore', 'pipe', 'pipe'] });
desktopEnv = env;
desktop = spawn('powershell.exe', ['-NoProfile', '-STA', '-File', script, terminal, binary, workspace, baseUrl, title, evidence, '-StageSeconds', String(stageSeconds)], { env, windowsHide: false, stdio: ['ignore', 'pipe', 'pipe'] });
desktop.stdout.on('data', data => { void fs.appendFile(path.join(evidence, 'desktop.log'), data); });
desktop.stderr.on('data', data => { desktopError += data; void fs.appendFile(path.join(evidence, 'desktop-error.log'), data); });
desktopExit = new Promise((resolve, reject) => { desktop.once('error', reject); desktop.once('exit', resolve); });
await until(() => exists('pasted.json'), 'actual clipboard paste');
desktopExit = new Promise((resolve, reject) => { desktop.once('error', reject); desktop.once('close', resolve); });
await until(() => exists('pasted.json'), 'actual clipboard paste', pasteBackstop);
assert.equal(requests.length, 0, 'pasting must not submit any partial turn');
receipt.requestsBeforeEnter = 0;
await fs.writeFile(path.join(evidence, 'submit'), 'enter once');
await until(() => exists('completed.json'), 'rendered fixture reply');
await until(() => exists('completed.json'), 'rendered fixture reply', completedBackstop);
await delay(1000);
assert.equal(requests.length, 1, 'one Enter must submit exactly one turn');
const matching = requests[0].filter(text => text.includes('cw-clipboard-01'));
@@ -143,8 +174,9 @@ try {
receipt.result = 'passed';
} catch (error) {
receipt.error = String(error); process.exitCode = 1;
if (desktop) await diagnose();
} finally {
if (desktop && desktop.exitCode === null) { desktop.kill(); await desktopExit.catch(() => {}); }
if (desktop && !desktopStopped()) { desktop.kill(); await desktopExit.catch(() => {}); }
server.closeAllConnections(); await new Promise(resolve => server.close(resolve));
await fs.writeFile(path.join(evidence, 'receipt.json'), JSON.stringify(receipt, null, 2));
console.log(JSON.stringify(receipt));
+203 -18
View File
@@ -1,24 +1,170 @@
param(
[Parameter(Mandatory)][string]$Terminal,
[Parameter(Mandatory)][string]$Binary,
[Parameter(Mandatory)][string]$Workspace,
[Parameter(Mandatory)][string]$BaseUrl,
[Parameter(Mandatory)][string]$Title,
[Parameter(Mandatory)][string]$Evidence
[string]$Terminal,
[string]$Binary,
[string]$Workspace,
[string]$BaseUrl,
[string]$Title,
[Parameter(Mandatory)][string]$Evidence,
# One budget for every desktop stage. The Node caller derives its backstop
# deadlines from this same number, so a stage always reports before Node
# gives up.
[int]$StageSeconds = 45,
# Capture the desktop and the window list under this name, then exit. The
# Node caller uses it when this script is stuck and cannot report itself.
[string]$DiagnoseOnly = ''
)
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes, System.Windows.Forms, System.Drawing
Add-Type @'
# Windows PowerShell 5.1 compiles this with the C# 5 compiler: no newer syntax.
Add-Type -IgnoreWarnings @'
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
public sealed class ClipboardFixtureWindowInfo {
public int ZOrder;
public long Handle;
public uint ProcessId;
public bool Foreground;
public bool Minimised;
public string Bounds;
public string ClassName;
public string Title;
}
public static class ClipboardFixtureWindow {
public delegate bool EnumWindowsCallback(IntPtr h, IntPtr state);
[StructLayout(LayoutKind.Sequential)] public struct Rect { public int Left; public int Top; public int Right; public int Bottom; }
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int command);
[DllImport("user32.dll")] static extern bool BringWindowToTop(IntPtr h);
[DllImport("user32.dll")] static extern bool IsIconic(IntPtr h);
[DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr h);
[DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr h, out uint processId);
[DllImport("user32.dll")] static extern bool AttachThreadInput(uint from, uint to, bool attach);
[DllImport("user32.dll")] static extern void keybd_event(byte key, byte scan, uint flags, UIntPtr extra);
[DllImport("user32.dll")] static extern bool EnumWindows(EnumWindowsCallback callback, IntPtr state);
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern int GetWindowText(IntPtr h, StringBuilder text, int capacity);
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern int GetClassName(IntPtr h, StringBuilder text, int capacity);
[DllImport("user32.dll")] static extern bool GetWindowRect(IntPtr h, out Rect rect);
[DllImport("kernel32.dll")] static extern uint GetCurrentThreadId();
static bool Owns(IntPtr h) { Thread.Sleep(60); return GetForegroundWindow() == h; }
// Windows refuses SetForegroundWindow from a process that neither owns the
// foreground nor sent the last input, which is the normal state of a CI
// step. Escalate through the documented ways around that lock and name
// the one that worked, so the evidence shows whether the lock was hit.
// No step here sends anything to the owned Terminal: the Alt tap is only
// issued while a different window still holds the foreground.
public static string Activate(IntPtr h, bool allowMinimise) {
if (GetForegroundWindow() == h) return "already";
ShowWindow(h, IsIconic(h) ? 9 : 5);
BringWindowToTop(h);
SetForegroundWindow(h);
if (Owns(h)) return "SetForegroundWindow";
IntPtr other = GetForegroundWindow();
uint otherProcess;
uint otherThread = other == IntPtr.Zero ? 0 : GetWindowThreadProcessId(other, out otherProcess);
uint thisThread = GetCurrentThreadId();
if (otherThread != 0 && otherThread != thisThread && AttachThreadInput(thisThread, otherThread, true)) {
try { BringWindowToTop(h); SetForegroundWindow(h); }
finally { AttachThreadInput(thisThread, otherThread, false); }
if (Owns(h)) return "AttachThreadInput";
}
if (GetForegroundWindow() != h) {
keybd_event(0x12, 0, 0, UIntPtr.Zero);
keybd_event(0x12, 0, 2, UIntPtr.Zero);
SetForegroundWindow(h);
}
if (Owns(h)) return "Alt+SetForegroundWindow";
if (allowMinimise) {
ShowWindow(h, 6);
Thread.Sleep(150);
ShowWindow(h, 9);
SetForegroundWindow(h);
if (Owns(h)) return "minimise+restore";
}
return "denied";
}
// Front-to-back visible top-level windows. Plain user32 calls only, so
// this still answers when UI Automation is the thing that is stuck.
public static ClipboardFixtureWindowInfo[] VisibleTopLevelWindows() {
List<ClipboardFixtureWindowInfo> rows = new List<ClipboardFixtureWindowInfo>();
IntPtr foreground = GetForegroundWindow();
EnumWindows(delegate(IntPtr h, IntPtr state) {
if (!IsWindowVisible(h)) return true;
StringBuilder title = new StringBuilder(512);
GetWindowText(h, title, title.Capacity);
StringBuilder className = new StringBuilder(256);
GetClassName(h, className, className.Capacity);
uint processId;
GetWindowThreadProcessId(h, out processId);
Rect r;
GetWindowRect(h, out r);
ClipboardFixtureWindowInfo info = new ClipboardFixtureWindowInfo();
info.ZOrder = rows.Count;
info.Handle = h.ToInt64();
info.ProcessId = processId;
info.Foreground = h == foreground;
info.Minimised = IsIconic(h);
info.Bounds = r.Left + "," + r.Top + "," + r.Right + "," + r.Bottom;
info.ClassName = className.ToString();
info.Title = title.ToString();
rows.Add(info);
return true;
}, IntPtr.Zero);
return rows.ToArray();
}
}
'@
$window = $null
$ownedPid = $null
$ownedHandle = [IntPtr]::Zero
$stage = 'starting'
function Add-EvidenceLine([string]$File, [string]$Text) {
$line = '{0:o} {1}' -f [DateTime]::UtcNow, $Text
[System.IO.File]::AppendAllText((Join-Path $Evidence $File), $line + "`r`n")
[Console]::Out.WriteLine("[$File] $line")
}
# The last line of stages.log names where a failed or killed run was; the
# timestamps show where a slow run spent its time.
function Enter-Stage([string]$Name) {
$script:stage = $Name
Add-EvidenceLine 'stages.log' $Name
}
function Save-WindowList([string]$Name) {
$rows = @(foreach ($info in [ClipboardFixtureWindow]::VisibleTopLevelWindows()) {
$path = $null
try { $path = (Get-Process -Id $info.ProcessId -ErrorAction Stop).Path } catch { }
[ordered]@{
zOrder = $info.ZOrder; foreground = $info.Foreground; minimised = $info.Minimised
title = $info.Title; className = $info.ClassName; bounds = $info.Bounds
processId = $info.ProcessId; processPath = $path
ownedTerminal = [bool]($ownedPid -and $info.ProcessId -eq $ownedPid)
fixtureTitle = [bool]($Title -and $info.Title.Contains($Title))
}
})
ConvertTo-Json -InputObject $rows -Depth 4 | Set-Content -Encoding UTF8 (Join-Path $Evidence "$Name-windows.json")
}
function Save-Desktop([string]$Name) {
$screen = [System.Windows.Forms.SystemInformation]::VirtualScreen
if ($screen.Width -le 0 -or $screen.Height -le 0) { throw 'No visible desktop' }
$bitmap = [System.Drawing.Bitmap]::new([int]$screen.Width, [int]$screen.Height)
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
try {
$graphics.CopyFromScreen([int]$screen.X, [int]$screen.Y, 0, 0, $bitmap.Size)
$bitmap.Save((Join-Path $Evidence "$Name-desktop.png"), [System.Drawing.Imaging.ImageFormat]::Png)
} finally { $graphics.Dispose(); $bitmap.Dispose() }
}
# Whole desktop plus every visible top-level window. The two parts are
# independent so one failing cannot hide the other or the original error.
function Save-Diagnostics([string]$Name) {
try { Save-WindowList $Name } catch { Write-Warning "window list ${Name}: $_" }
try { Save-Desktop $Name } catch { Write-Warning "desktop capture ${Name}: $_" }
}
function Read-Terminal {
$texts = $window.FindAll([System.Windows.Automation.TreeScope]::Descendants,
[System.Windows.Automation.PropertyCondition]::new([System.Windows.Automation.AutomationElement]::IsTextPatternAvailableProperty, $true))
@@ -27,12 +173,12 @@ function Read-Terminal {
}) -join "`n")
}
function Wait-For([scriptblock]$Check, [string]$Label) {
$deadline = [DateTime]::UtcNow.AddSeconds(45)
$deadline = [DateTime]::UtcNow.AddSeconds($StageSeconds)
while ([DateTime]::UtcNow -lt $deadline) {
if (& $Check) { return }
Start-Sleep -Milliseconds 200
}
throw "Deadline waiting for $Label"
throw "Deadline (${StageSeconds}s) waiting for $Label"
}
function Capture([string]$Name) {
(Read-Terminal) | Set-Content -Encoding UTF8 (Join-Path $Evidence "$Name.txt")
@@ -45,21 +191,44 @@ function Capture([string]$Name) {
$bitmap.Save((Join-Path $Evidence "$Name.png"), [System.Drawing.Imaging.ImageFormat]::Png)
} finally { $graphics.Dispose(); $bitmap.Dispose() }
}
function Focus-OwnedWindow {
function Focus-OwnedWindow([string]$Reason) {
Enter-Stage "focus $Reason"
$process = Get-Process -Id $ownedPid
if ($process.Path -ne $Terminal) { throw 'Terminal process identity changed' }
$handle = [IntPtr]$window.Current.NativeWindowHandle
[void][ClipboardFixtureWindow]::ShowWindow($handle, 9)
[void][ClipboardFixtureWindow]::SetForegroundWindow($handle)
Wait-For { [ClipboardFixtureWindow]::GetForegroundWindow() -eq $handle } 'owned Terminal focus'
$script:ownedHandle = $handle
# Re-assert until the owned window holds the foreground on two consecutive
# looks. Only focus is retried: the paste and the Enter are each sent once.
$attempt = 0
$deadline = [DateTime]::UtcNow.AddSeconds($StageSeconds)
while ([DateTime]::UtcNow -lt $deadline) {
$attempt++
$method = [ClipboardFixtureWindow]::Activate($handle, ($attempt -ge 3))
if ($attempt -le 5 -or $method -ne 'denied') { Add-EvidenceLine 'focus.log' "$Reason attempt=$attempt method=$method" }
if ($method -ne 'denied') {
Start-Sleep -Milliseconds 150
if ([ClipboardFixtureWindow]::GetForegroundWindow() -eq $handle) { return }
}
Start-Sleep -Milliseconds 350
}
throw "Deadline (${StageSeconds}s) waiting for owned Terminal focus $Reason after $attempt activation attempts"
}
if ($DiagnoseOnly) {
Save-Diagnostics $DiagnoseOnly
exit 0
}
try {
Enter-Stage 'launch'
# All arguments are owned fixture paths; the fake key cannot reach a paid provider.
foreach ($arg in @($Terminal, $Binary, $Workspace, $BaseUrl, $Title)) {
if (-not $arg) { throw 'Missing fixture argument' }
if ($arg.Contains('"')) { throw 'Unexpected quote in fixture argument' }
}
# What the runner desktop held before this fixture added its own window.
Save-Diagnostics 'start'
$arguments = "-w new new-tab --title `"$Title`" --suppressApplicationTitle --startingDirectory `"$Workspace`" `"$Binary`" --workspace `"$Workspace`" --no-project-config --fresh --provider openai --model gpt-4o-mini --api-key clipboard-fixture-only --base-url `"$BaseUrl`""
Start-Process -FilePath $Terminal -ArgumentList $arguments | Out-Null
Enter-Stage 'window'
Wait-For {
$windows = [System.Windows.Automation.AutomationElement]::RootElement.FindAll(
[System.Windows.Automation.TreeScope]::Children, [System.Windows.Automation.Condition]::TrueCondition)
@@ -73,30 +242,46 @@ try {
}
return $false
} 'owned Windows Terminal window'
Focus-OwnedWindow
Focus-OwnedWindow 'before composer'
Enter-Stage 'composer'
Wait-For { (Read-Terminal).Contains('Type a message') } 'Codewhale composer'
Capture 'ready'
Enter-Stage 'clipboard'
$payload = [System.IO.File]::ReadAllText((Join-Path $Evidence 'expected.txt'), [System.Text.Encoding]::UTF8)
[System.Windows.Forms.Clipboard]::SetText($payload)
if ([System.Windows.Forms.Clipboard]::GetText() -ne $payload) { throw 'Clipboard readback mismatch' }
Focus-OwnedWindow
Focus-OwnedWindow 'before paste'
Enter-Stage 'paste'
[System.Windows.Forms.SendKeys]::SendWait('^+v')
$foregroundAfterPaste = [ClipboardFixtureWindow]::GetForegroundWindow() -eq $ownedHandle
# Let every pasted line arrive before asking the independent HTTP observer
# whether anything was submitted prematurely.
Start-Sleep -Milliseconds 1800
Capture 'pasted'
@{ processId = $ownedPid; sessionId = (Get-Process -Id $ownedPid).SessionId; chars = $payload.Length; input = 'Windows clipboard + Ctrl+Shift+V' } |
@{ processId = $ownedPid; sessionId = (Get-Process -Id $ownedPid).SessionId; chars = $payload.Length; input = 'Windows clipboard + Ctrl+Shift+V'; foregroundAfterPaste = $foregroundAfterPaste } |
ConvertTo-Json | Set-Content -Encoding UTF8 (Join-Path $Evidence 'pasted.json')
Enter-Stage 'await submit'
Wait-For { Test-Path (Join-Path $Evidence 'submit') } 'independent no-submit assertion'
Focus-OwnedWindow
Focus-OwnedWindow 'before Enter'
Enter-Stage 'enter'
[System.Windows.Forms.SendKeys]::SendWait('{ENTER}')
Enter-Stage 'reply'
Wait-For { (Read-Terminal).Contains('cw-clipboard-ok') } 'actual provider reply'
Capture 'completed'
@{ enterCount = 1; renderedReply = 'cw-clipboard-ok' } | ConvertTo-Json |
Set-Content -Encoding UTF8 (Join-Path $Evidence 'completed.json')
Enter-Stage 'done'
} catch {
$failure = $_
# Stage and message first, then the desktop (no UI Automation involved),
# then the Terminal text, which needs the UI Automation that may have failed.
try {
@{ stage = $stage; error = "$failure" } | ConvertTo-Json |
Set-Content -Encoding UTF8 (Join-Path $Evidence 'failure.json')
} catch { Write-Warning "failure record: $_" }
Save-Diagnostics 'failure'
if ($window) { try { Capture 'failure' } catch { Write-Warning $_ } }
throw
throw $failure
} finally {
# Retire only the portable Terminal instance proven to belong to this fixture.
if ($ownedPid) {