Skip to content

Add node-swap: one reusable Windows fleet-node binary swap tool - #476

Merged
dmmdea merged 2 commits into
mainfrom
feat/windows-node-swap
Sep 24, 2026
Merged

dmmdea merged 2 commits into
mainfrom
feat/windows-node-swap

Conversation

@dmmdea

@dmmdea dmmdea commented Sep 24, 2026

Copy link
Copy Markdown
Owner

Summary

Root-cause fix for the 2026-09-24 Aorus outage (down 02:56-05:30): the 0.140.8 swap
script's restart-and-verify phase ran inside an interactive SSH session. Windows
OpenSSH kills the whole remote process tree on client disconnect, so when the
session dropped at the 120s mark the script had already swapped the binary but
never reached its schtasks /run / health-verify / rollback lines. A second hazard
the same day: an idle offload-harness.exe mcp process on the OptiPlex held an
OS-level handle on the live exe and silently blocked Move-Item/Rename-Item.

  • local-offload node-swap (internal/nodeswap/, wired in node_swap_cmd.go)
    is the one reusable engine every future Windows fleet-node deploy calls, replacing
    the family of hand-adapted per-release scripts (aorus-swap-<sha>.ps1,
    deploy-node-exe.ps1, fleet-node-restart.ps1 stitched together each time).
    Sequence: verify staged sha256 → wait idle (/fleet/health queue 0) → stop the
    node → backup-rename the old exe (on failure, diagnose the holder via CIM and
    clear ONLY an idle MCP-helper process sharing the exe, never a live server) →
    install the new exe → optional render-tree swap (tarball + backup + extract +
    hash-verify) → restart (scheduled task or an arbitrary restart command) → prove
    it by PID + running-image sha256 + health within a timeout → automatic
    rollback
    (restore backup, restart, re-verify) on any failure from the render
    swap onward.
  • setup/windows-node-swap-launch.ps1 launches it via Win32_Process.Create
    (never Start-Process, which dies with the SSH session) so a run survives the
    launching connection dropping and keeps writing --log/--result and still
    reaches its own rollback path.
  • docs/systems/node-swap.md (full system doc) + pointers from
    docs/FLEET-NODE.md, docs/README.md, AGENTS.md.

Every OS operation (hash, health read, CIM process enumeration, rename, tar.gz
extraction, Start/Stop-ScheduledTask) sits behind a Deps function-value seam,
so the whole state machine — every rollback branch included — is unit-tested with
fakes, no real Windows box required for CI (which runs on ubuntu-latest).

How tested

  • go build ./... / go vet ./... / go test ./... green on GOOS=linux (matches
    CI) and cross-compiled clean for GOOS=windows.
  • internal/nodeswap: 20+ unit tests covering the happy path, hash mismatch,
    never-idle timeout, rename-retry (idle MCP holder stopped vs. a live fleet-serve
    holder left alone), restart failure → rollback, post-restart-verify failure →
    rollback, render-tree swap rolled back on a later failure, standalone-node
    hash-only verification, plus the real cross-platform primitives (hashing,
    /fleet/health decoding incl. the pre-0.100.0 queue_depth fallback, tar.gz
    extraction incl. a path-escape refusal).
  • node_swap_cmd_test.go: CLI flag parsing.
  • Live on the Qube (this machine): built the branch's Windows exe and stood up
    a scratch fleet-serve node (own port, own dir — the real production Qube/Aorus
    fleet nodes were never touched). --dry-run verified live (touched nothing).
    Then the real swap, launched over ssh localhost through
    windows-node-swap-launch.ps1, with the ssh client process killed by PID
    (SIGKILL) 3 seconds after launch
    — well before the swap's own multi-step
    sequence (7.4s total) finished. The node came up on the swapped binary anyway:
    new PID, image sha256 matching the staged exe, /fleet/health answering
    correctly — all confirmed over a fresh connection after the kill.
    docs/systems/node-swap.md's testing-notes section and the PR conversation have
    the full transcript.
  • Rollback path: fully covered by deterministic unit tests (a forced restart
    failure rolls back, restarts again, and re-verifies the old binary — including
    when that first restart attempt fails and the SECOND, rollback-triggered restart
    attempt succeeds). A live-Windows rollback demo against the same scratch node hit
    an environment-specific hang traced to the throwaway test's own Start-Process-
    based restart script (a Go exec.Cmd stdout/stderr pipe-inheritance hang when a
    child spawns a long-lived grandchild that isn't fully detached) — not the shipped
    Win32_Process.Create-based restart pattern the tool actually uses in production
    (the same mechanism fleet-node-restart.ps1 already uses successfully in every
    real deploy record). Noted here rather than rounded up to "fully verified live."

Risk: medium (deploy-path tooling)

New capability, additive only — no existing command's behavior changed (git diff
against main touches only main.go's dispatch switch + usage text,
AGENTS.md/docs/README.md/docs/FLEET-NODE.md pointers, and net-new files).
Nothing currently deployed calls node-swap yet; adopting it on a real node is a
follow-up, operator-authorized step.

Co-Authored-By: Claude Opus 5.5 (1M context) noreply@anthropic.com

🤖 Generated with Claude Code

dmmdea and others added 2 commits September 24, 2026 07:28
Root-cause fix for the 2026-09-24 Aorus outage (02:56-05:30): the 0.140.8
swap script's restart-and-verify phase ran inside an interactive SSH
session, and Windows OpenSSH kills the whole remote process tree on client
disconnect. The script had already swapped the binary but never reached
its restart/verify/rollback lines when the session dropped.

`local-offload node-swap` (internal/nodeswap/) replaces the family of
hand-adapted per-deploy scripts (aorus-swap-<sha>.ps1, deploy-node-exe.ps1,
fleet-node-restart.ps1 stitched together each release) with one engine:
verify hash -> wait idle -> backup+rename (diagnosing and clearing only an
idle MCP-helper holder on a rename failure, never a live server) -> install
-> optional render-tree swap -> restart -> verify PID+image-sha256+health
within a timeout -> automatic rollback on any failure from the render swap
onward. Every OS operation sits behind a Deps seam, so the full sequence
including every rollback branch is unit-tested with fakes.

setup/windows-node-swap-launch.ps1 launches it via Win32_Process.Create
(never Start-Process, which dies with the SSH session) so a run survives
the launching connection dropping and still reaches its own rollback path.

Tested: go test ./... green on GOOS=linux (CI) and cross-compiled for
GOOS=windows; a dry run and the real swap exercised live on the Qube
against a scratch fleet node, including a genuinely killed ssh client
mid-flight (the node came up on the swapped binary, proven by new PID +
image sha256 + /fleet/health, entirely after the ssh client process was
gone). The rollback path is covered by deterministic unit tests
(restart-failure and post-restart-verify-failure both roll back and
re-verify the old binary); a live rollback demo on the scratch node hit an
environment-specific hang tied to the throwaway test binary/script, not
the shipped restart mechanism (see the PR description).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Addresses the pr-review-toolkit:code-reviewer findings on PR #476.

BLOCKING: a failure at the backup-old or install-new step happened AFTER
stop-node had already stopped the node, but neither path routed through
rollback() or ever restarted it -- the node was left DOWN, the exact
outage class this tool exists to prevent (a Windows file lock is a
realistic trigger for backup-old; a staged file vanishing/corrupting for
install-new). Both steps now route through rollback(), which is extended
to handle the case where nothing was ever moved (Target still holds the
original binary untouched): it skips the file restore and goes straight
to restart+verify. Two new tests (TestRun_BackupOldFailureRestartsOldBinary,
TestRun_InstallNewFailureRestartsOldBinary) pin this, confirmed red against
the pre-fix code before being made green. Docs (FLEET-NODE.md,
docs/systems/node-swap.md) corrected to describe the real coverage
(rollback from step 4 onward, not "the render swap onward").

SHOULD-FIX: windows-node-swap-launch.ps1's Quote-Arg doubled embedded `"`
(PowerShell/cmd convention), but the receiving process is a Go binary
parsing argv via the CommandLineToArgvW/MSVCRT convention, which needs
`\"` plus the backslash-run-before-a-quote doubling rule. A --restart-command
wrapping a quoted inner PowerShell command silently corrupted argv under
the old scheme. Replaced with Quote-ArgForNativeArgv, a verbatim port of
Go's syscall.EscapeArg, verified with a -SelfTest mode that round-trips
its output through the REAL Win32 CommandLineToArgvW API (not just
hand-computed expected strings) -- run live on the Qube and wired into
CI's installer-windows job via the new
setup/windows-node-swap-launch.tests.ps1.

SHOULD-FIX: a flag-parsing or --log-open failure in node_swap_cmd.go used
to exit with neither --log nor --result written -- indistinguishable to a
detached poller from "still running". runNodeSwap now pre-scans args for
--result/--log before the real parse and writes a minimal failure Outcome
to whatever paths it found on either failure path. Two new tests
(TestRunNodeSwap_BadFlagStillWritesAResult,
TestRunNodeSwap_UnopenableLogStillWritesAResult), confirmed red without
the fix. The launcher also gained a defense-in-depth liveness check after
Win32_Process.Create.

Also added TestRun_RollbackRestoreItselfFails, asserting RollbackOK
surfaces false rather than a silent false recovery when the rollback's
own file restore fails.

Tested: go test ./... green on GOOS=linux and cross-compiled for
GOOS=windows; go vet clean on both; the argv self-test run live.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@dmmdea
dmmdea merged commit 848ae52 into main Sep 24, 2026
5 checks passed
@dmmdea
dmmdea deleted the feat/windows-node-swap branch September 24, 2026 12:52
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