Skip to content

feat(queue): dispatch to Windows psmux hosts alongside POSIX remotes - #84

Merged
LeTuR merged 1 commit into
mainfrom
feat/psmux-host
Sep 13, 2026
Merged

feat(queue): dispatch to Windows psmux hosts alongside POSIX remotes#84
LeTuR merged 1 commit into
mainfrom
feat/psmux-host

Conversation

@LeTuR

@LeTuR LeTuR commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Intent

Fleet task remote-host-support/02-02-psmux-and-windows. The operator asked, verbatim: 'Fleet disaptching to a psmux should work, maybe running fleet on windows should work also ?'. The brief required a decision with evidence and the smallest change that makes it real: verify each of the three couplings the old refusal cited (probes, brief copy, result fetch) against the real Windows host win-host (PowerShell 5, psmux 3.3.6), answer A (dispatch TO Windows) and B (fleet RUNNING on Windows) separately, and build A only if contained, as a seam per coupling with the POSIX implementation unchanged and a PowerShell one beside it, driven by a test to the standard scripts/lib/forge.py set (second implementation driven with a tripwire on PATH), proven against win-host. Hard constraints: do not regress the POSIX path (existing remote selftests unchanged), do not weaken the refusal to make the error go away (a half-working spawn would leave a worktree on a Windows box), no new dependency (no PowerShell module, Cygwin or busybox), say what could not be tested. B was to be answered, not implemented. Findings and decisions: all three couplings were real (printf/[ -d ] do not exist in PowerShell; cat > path writes nothing; PS5 stdin/stdout go through the ibm850 code page so non-ASCII does not survive), plus a fourth the refusal never named: the trust handoff (psmux captures panes with spaces removed, so session-trust.sh's signature never matched; Claude Code 2.1.247 on Windows pre-selects 'Yes', so the table's 'down enter' chose 'No, exit' and the agent exited - observed live). Built: a HostShell seam in scripts/lib/queue.py selected by hosts.toml's multiplexer (the same proxy thurbox uses, HostDef::is_windows): PosixShell issues byte-for-byte the previous commands including the /bin/sh -lc login wrap; PowerShell sends UTF-16LE base64 -EncodedCommand (thurbox's own host_powershell_c rule, because -Command quoting is rewritten by the host's default shell), carries file bytes as base64 both directions, joins paths with backslash, and runs the forge probe's ssh key check via bounded Start-Process because inline '& ssh' hangs inside an ssh session on Windows (measured: & ssh, ssh -n and cmd /c <NUL all hung past 45s). Any multiplexer other than tmux/psmux is still refused by name at add; a Windows box that hosts.toml calls tmux is still caught by the first probe. Deliberately, exit codes carry no meaning beyond zero/non-zero and ssh's 255, because Windows folds a PowerShell exit code; answers are words on stdout sharing one line protocol with the POSIX scripts. reportable() strips OpenSSH's post-quantum advisory and PowerShell CLIXML / error trailer so failure reasons name the real error - the only POSIX-visible change is that error text. session-trust.sh now matches whitespace-insensitively and sends Enter alone when the selector already sits on the accepting option; a dialog defaulting to No still gets 'down enter'. Tests in scripts/queue-selftest.sh §11(i)/(j): a fake Windows host (windows-host.py in the ssh stub) decodes every -EncodedCommand, moves real bytes into a fake C:\ tree, and logs any non-encoded (POSIX) command as a tripwire, with a claim proving the tripwire fires; §11(a-h) unchanged except the refusal row now uses oddbox (unknown multiplexer) instead of winbox. The Windows reap claim holds its topic open with a condition-blocked task because of a pre-existing, deliberately unfixed bug: a topic whose last remote task lands while its host is down archives in the same pass and reap then never revisits the kept session. Known limits, accepted: thurbox gates hook status off on psmux hosts so Windows workers stay 'unreported' and reap never releases them; win-host has no GitHub credentials so no full worker run was possible; GitLab origin, non-22 ssh port and PowerShell 7 on Windows are untested. B answered in docs/PR only: yes with WSL, native is a port of 22 bash scripts plus the setsid supervisor. Docs updated: scripts/queue.sh header, fleet-queue and thurbox-session skills. This run exists to attest PR #80 (task was queued as 'pr' by the #74 bug; the operator's standing method is attested).

What Changed

  • Added a HostShell seam in scripts/lib/queue.py (PosixShell plus a new PowerShell implementation) so remote dispatch's three probes, brief copy, and result fetch each speak the shell a host actually runs: POSIX for a tmux host (byte-for-byte unchanged, including the /bin/sh -lc login wrap), PowerShell for a psmux (native Windows) host via UTF-16LE base64 -EncodedCommand, base64 file transfer in both directions, backslash path joining, and a bounded Start-Process SSH key check to avoid an inline & ssh hang observed on Windows. host_entry() now refuses any multiplexer other than tmux/psmux by name instead of refusing every non-tmux host.
  • Added reportable() to strip OpenSSH's post-quantum advisory and PowerShell's CLIXML/error-trailer noise from probe output so reported failures name the real error, on both POSIX and Windows hosts.
  • Updated scripts/session-trust.sh to match trust-dialog signatures with whitespace stripped from both sides (psmux captures panes with spaces removed) and to send Enter alone whenever the dialog's selector is already on the accepting option, instead of always sending the fixed down enter sequence that would otherwise pick "No, exit" on Claude Code's Windows-defaulted dialog.
  • Extended scripts/queue-selftest.sh with a fake Windows host (decoding -EncodedCommand, writing into a fake C:\ tree, and tripwiring any non-encoded POSIX command) covering the new PowerShell path, updated the existing refusal-row test to use an unknown multiplexer name instead of assuming any non-tmux host is refused, and updated scripts/queue.sh's header comment plus the fleet-queue/thurbox-session skill docs to describe the two-shell behavior.

Risk Assessment

✅ Low: The fix-round change (scripts/lib/queue.py:2515, switching to [Console]::Out.Write) correctly bypasses PowerShell's console-width formatter, resolving the line-wrap truncation risk flagged in round 1; the rest of the HostShell seam, session-trust.sh's whitespace-insensitive/selector-aware matching, and the behavioral (non-string-matching) selftest coverage are consistent, well-scoped, and match the stated intent with no regression to the POSIX path.

Testing

Baseline ./scripts/check.sh (full gate, including the complete queue-selftest.sh with its Windows/psmux dispatch and result-fetch coverage) already passed with no regressions. Because that suite's fake Windows-host stub doesn't model PowerShell's real non-interactive line-wrapping and no PowerShell interpreter is available on this machine, I wrote and ran a focused manual verification against the real decode()/last_out_of() functions using the actual pre-fix and post-fix read() output shapes and the suite's own realistic result.md fixture: the pre-fix shape silently decodes to wrong bytes (the reported bug, reproduced), and the post-fix shape decodes to the exact original bytes, confirming the fix closes the gap for a realistic payload.

Evidence: Manual verification script (models pre-fix vs post-fix PowerShell output shapes through the real decode/last_out_of)
#!/usr/bin/env python3
"""Manual verification for the psmux/Windows result-fetch line-wrap fix.

Real PowerShell 5, run non-interactively over an ssh exec channel (no pty),
cannot query a console width and falls back to a default host buffer width of
80 columns. A plain string emitted as the pipeline's *implicit* output goes
through the default formatter (Out-Default), which hard-wraps continuous text
(no whitespace to break on) at that width when writing it out. `[Console]::
Out.Write(...)` instead writes straight to the stdout stream, bypassing the
formatter/pipeline entirely, so it is never wrapped.

`scripts/lib/queue.py`'s `PowerShell.decode()` recovers the base64 payload
with `last_out_of(stdout)`, which keeps only the LAST non-empty line. Wrapped
output would silently discard every earlier chunk.

This script imports the real `last_out_of`/`PowerShell.decode` from the
target commit (unchanged across both commits) and feeds it two prepared
stdout strings that model what real PowerShell would hand back for the two
`read()` implementations:
  - pre-fix (commit 5efbde3): bare `[Convert]::ToBase64String(...)` as the
    script's last expression -> implicit pipeline output -> wrapped.
  - post-fix (commit 873c14c): `[Console]::Out.Write(...)` -> direct stream
    write -> not wrapped.

No real PowerShell interpreter is available on this machine (no pwsh/
powershell, and installing one is out of scope for this worktree), so the
wrapping step is a documented-behavior model, not a captured real PowerShell
run. It is not a substitute for running this against a real Windows host;
the operator's own PR notes that win-host validation already covered the
happy path with a small enough payload to not trigger the wrap.
"""
import base64
import sys

sys.path.insert(0, "scripts/lib")
import queue as q  # noqa: E402

BUFFER_WIDTH = 80

# The exact fixture queue-selftest.sh §11(i) writes as the Windows worker's
# result.md (a realistic ~140-byte real task result, non-ASCII included).
RESULT_MD = (
    "---\noutcome: shipped\n"
    "artifact: https://github.com/remote-owner/app/pull/4343\n---\n"
    "Built it on winbox — café ✓.\r\nWritten by Windows.\r\n"
).encode()


def wrapped_stdout(payload_b64: str) -> str:
    """What PowerShell's default formatter emits for a bare-expression string
    result when no real console width can be queried: hard-wrapped at 80
    columns, one physical line per chunk."""
    return "\n".join(
        payload_b64[i : i + BUFFER_WIDTH] for i in range(0, len(payload_b64), BUFFER_WIDTH)
    )


def direct_stdout(payload_b64: str) -> str:
    """What `[Console]::Out.Write(...)` emits: the raw string, one line."""
    return payload_b64


payload_b64 = base64.b64encode(RESULT_MD).decode("ascii")
print(f"fixture: {len(RESULT_MD)} raw bytes -> {len(payload_b64)} base64 chars "
      f"(> {BUFFER_WIDTH}-column buffer width, so wrapping would trigger)\n")

ps = q.PowerShell()

pre_fix_stdout = wrapped_stdout(payload_b64)
post_fix_stdout = direct_stdout(payload_b64)

print("--- pre-fix behavior model (bare expression -> Out-Default wraps) ---")
print(f"stdout lines: {len(pre_fix_stdout.splitlines())}")
decoded = ps.decode(pre_fix_stdout)
if decoded is None:
    print("decode() result: None (ValueError swallowed) -- fetch_result would "
          "report a loud, safe failure")
elif decoded.encode("utf-8", "surrogateescape") == RESULT_MD:
    print("decode() result: UNEXPECTEDLY matched original bytes")
else:
    print("decode() result: silently WRONG (truncated/garbage) bytes -- the bug")
print()

print("--- post-fix behavior model ([Console]::Out.Write -> single line) ---")
print(f"stdout lines: {len(post_fix_stdout.splitlines())}")
decoded = ps.decode(post_fix_stdout)
ok = decoded is not None and decoded.encode("utf-8", "surrogateescape") == RESULT_MD
print(f"decode() result matches original {len(RESULT_MD)}-byte result.md exactly: {ok}")

sys.exit(0 if ok else 1)
Evidence: Verification run output: pre-fix shape decodes to wrong bytes, post-fix shape decodes exactly

fixture: 137 raw bytes -> 184 base64 chars (> 80-column buffer width, so wrapping would trigger) --- pre-fix behavior model (bare expression -> Out-Default wraps) --- stdout lines: 3 decode() result: silently WRONG (truncated/garbage) bytes -- the bug --- post-fix behavior model ([Console]::Out.Write -> single line) --- stdout lines: 1 decode() result matches original 137-byte result.md exactly: True

fixture: 137 raw bytes -> 184 base64 chars (> 80-column buffer width, so wrapping would trigger)

--- pre-fix behavior model (bare expression -> Out-Default wraps) ---
stdout lines: 3
decode() result: silently WRONG (truncated/garbage) bytes -- the bug

--- post-fix behavior model ([Console]::Out.Write -> single line) ---
stdout lines: 1
decode() result matches original 137-byte result.md exactly: True
- Outcome: ⚠️ 1 info across 1 run (9m2s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 1 issue found → auto-fixed ✅
  • ⚠️ scripts/lib/queue.py:2515 - PowerShell.read() emits the base64 payload via the pipeline's implicit output ([Convert]::ToBase64String(...) as the script's last expression), which goes through PowerShell's default Out-Default/console-formatting layer. When powershell.exe runs non-interactively over an exec-channel ssh session (no pty is allocated here — ssh_argv has no -t), it cannot query a real console width and falls back to a default buffer width (commonly 80), and that formatter wraps long string output at that width even though it's being redirected to a pipe, not a terminal — a well-known PowerShell gotcha for exactly this invocation shape. PowerShell.decode() (line 2518-2522) recovers the payload with base64.b64decode(last_out_of(stdout), ...), and last_out_of (line 2235) returns only the LAST non-empty line of stdout. Any result.md whose base64 encoding exceeds one wrapped line (roughly >60 raw bytes — true of essentially any real task result, e.g. the test's own ~140-byte fixture result) would have its earlier lines silently discarded, leaving only the tail chunk to decode. This mechanism isn't exercised by the new queue-selftest.sh (i) coverage because the fake windows-host.py stub emits the base64 with a single Python print() and never simulates PowerShell's own line-wrapping, so the gap is untested. Depending on the wrapped tail's length modulo 4, decode() will usually raise ValueError (safe, loud failure — fetch_result reports "the host answered, but not with the file's bytes") but could also occasionally decode to truncated-but-valid-looking bytes, silently returning a wrong (truncated) result. Either way this would make the freshly-built "result fetch" coupling non-functional for realistic Windows task outputs, contradicting the intent's claim that this coupling was verified end-to-end. The fix is mechanical: have the PowerShell script write the base64 string directly to the output stream (e.g. [Console]::Out.Write(...)) instead of relying on the formatted pipeline output, bypassing the width-based wrapping entirely.

🔧 Fix: fix(queue): write PowerShell result base64 with Console.Out to avoid wrap truncation
✅ Re-checked - no issues remain.

⚠️ **Test** - 1 info
  • ℹ️ The existing queue-selftest.sh section 11(i) Windows result-fetch coverage still round-trips base64 through a plain Python print() in its ssh stub and does not model PowerShell's non-interactive console-width line-wrapping, so it cannot regression-guard this specific fix on its own; it did not need to per the recorded review decision (code-only auto-fix), and real end-to-end confirmation would require a real Windows/PowerShell host, which the operator's own PR notes was only available for a payload too small to trigger the wrap.
  • ./scripts/check.sh
  • ./scripts/check.sh (baseline, already run successfully before this phase - includes the full scripts/queue-selftest.sh, notably section 11(i)/(j) Windows/psmux dispatch and result-fetch coverage)
  • git show 5efbde3:scripts/lib/queue.py and git show 873c14c:scripts/lib/queue.py - diffed PowerShell.read() across the pre-fix and post-fix commits to confirm the exact code shapes exercised below
  • python3 verify_wrap_fix.py - manual verification importing the real queue.py PowerShell.decode()/last_out_of(), feeding it a modeled pre-fix (wrapped) vs post-fix (unwrapped, [Console]::Out.Write) stdout for the real ~137-byte non-ASCII result.md fixture used by queue-selftest.sh section 11(i)
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

@LeTuR
LeTuR merged commit c90b704 into main Sep 13, 2026
11 checks passed
@LeTuR
LeTuR deleted the feat/psmux-host branch September 13, 2026 05:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant