test(install-e2e/windows): the first chat turn exits under the pseudoconsole

Every Windows leg of every scheduled run since 2026-09-25 was cancelled at
the 60-minute job timeout in its Install step. The user-state phase runs
`hermes chat -q "..."` under pty-run.py (a ConPTY, so the CLI sees a real
TTY). Since a5c7eed (shipped in v2026.9.21) `-q` on a TTY seeds an
interactive session and only answers-and-exits with --oneshot; the turn
answered ("Hello from the mock inference server!") and then sat at the
prompt. pty-run.py's --timeout 900 never fired either: it checked the
deadline only between blocking PtyProcess.read() calls, and an idle prompt
never returns from read().

- windows-e2e.ps1 passes --oneshot when `chat --help` advertises it (older
  tags have no flag and exit after -q on their own), lowers the turn budget
  to 300s, and names a 124 as "never exited" instead of a generic failure.
- pty-run.py drains the pty on a daemon thread and waits on the queue with
  the deadline, so --timeout holds whatever the child does, and writes a
  TIMEOUT line into the captured log before killing it.
This commit is contained in:
teknium1
2026-09-26 17:51:42 -07:00
committed by Teknium
parent d25bbd01b7
commit 116b3e2f9e
2 changed files with 49 additions and 12 deletions

View File

@@ -24,41 +24,69 @@ Exits with the child's exit code (124 if the timeout killed it).
from __future__ import annotations
import argparse
import os
import queue
import subprocess
import sys
import threading
import time
def _run_under_pty(argv: list[str], out_path: str, cwd: str | None, cols: int, rows: int,
timeout: float | None) -> int | None:
"""Return the child's exit code, or None when pywinpty is unavailable."""
"""Return the child's exit code, or None when pywinpty is unavailable.
``PtyProcess.read`` blocks until the child writes, so the deadline cannot be
checked between reads: a child that sits idle (an interactive prompt waiting
for input) would never return control and the step would hang until the
job's own timeout cancelled it with no diagnosis. A daemon thread drains the
pty into a queue instead, and this thread waits on the queue with the
deadline, so ``--timeout`` holds no matter what the child does.
"""
try:
from winpty import PtyProcess
except ImportError:
return None
proc = PtyProcess.spawn(argv, cwd=cwd, dimensions=(rows, cols))
chunks: queue.Queue[str | None] = queue.Queue()
def _drain() -> None:
try:
while True:
chunk = proc.read(4096)
if not chunk and not proc.isalive():
break
if chunk:
chunks.put(chunk)
except EOFError:
pass
finally:
chunks.put(None)
threading.Thread(target=_drain, name="pty-run-drain", daemon=True).start()
deadline = None if timeout is None else time.monotonic() + timeout
timed_out = False
with open(out_path, "w", encoding="utf-8", errors="replace") as log:
while True:
if deadline is not None and time.monotonic() > deadline:
timed_out = True
proc.terminate(force=True)
break
wait = None if deadline is None else max(0.0, deadline - time.monotonic())
try:
chunk = proc.read(4096)
except EOFError:
chunk = chunks.get(timeout=wait)
except queue.Empty:
timed_out = True
break
if not chunk:
if chunk is None:
break
log.write(chunk)
log.flush()
sys.stdout.write(chunk)
sys.stdout.flush()
if not proc.isalive():
break
if timed_out:
note = (f"\npty-run: TIMEOUT after {timeout:g}s: the child was still running and had "
"stopped writing (waiting for input?); killing it.\n")
log.write(note)
sys.stdout.write(note)
sys.stdout.flush()
proc.terminate(force=True)
proc.wait()
if timed_out:
return 124

View File

@@ -1220,15 +1220,24 @@ function Invoke-UserStateActions {
if (-not ($chatHelp -match '(^|\s)-q(\s|,|$)' -or $chatHelp -match '--quiet')) {
throw 'the installed CLI has no one-shot chat flag; this leg cannot produce a session through the user path'
}
# Since a5c7eed (v2026.9.21+) `-q` on a real TTY seeds an INTERACTIVE session and
# only answers-and-exits with --oneshot (or on non-TTY stdio). The pseudoconsole
# below IS a TTY, so without --oneshot the turn answers and then sits at the
# prompt forever. Older tags have no --oneshot and exit after -q on their own.
$oneshot = @()
if ($chatHelp -match '--oneshot') { $oneshot = @('--oneshot') }
$before = Get-UserStateSessionCount
$log = Join-Path $WorkRoot 'logs\user-state-chat.log'
# A released tag prints through prompt_toolkit, whose Windows output object needs
# a console screen buffer: piping the CLI's stdout into the log takes that away
# and the turn dies with NoConsoleScreenBufferError. Run it under a real
# pseudoconsole (pty-run.py) and keep the capture.
& python -B (Join-Path $AssetsDir 'pty-run.py') --out $log --timeout 900 -- $hermes chat -q "Reply with the single word: ok"
& python -B (Join-Path $AssetsDir 'pty-run.py') --out $log --timeout 300 -- $hermes chat -q "Reply with the single word: ok" @oneshot
$chatExit = $LASTEXITCODE
Write-LogGroup 'first real chat turn' $log
if ($chatExit -eq 124) {
throw "the first chat turn never exited (still running after 300s; flags: chat -q $($oneshot -join ' ')); see $log"
}
if ($chatExit -ne 0) { throw "the first chat turn failed (exit $chatExit); see $log" }
$after = Get-UserStateSessionCount
# A fresh install has no state.db until the first turn: -1 means "no