Raft HA deploy runbook — shadow drill + live flip (Jira FLT-49, executed by WT-902b)¶
Andrew-ordered 2026-07-17: "prep the firewall commands and shadow cluster config and then run the drill and if it works than flip." This file is the exact procedure + the record of what ran.
Fleet facts (verified live 2026-07-17)¶
| Role | nodeId | Host | Tailnet IP | Notes |
|---|---|---|---|---|
| Voter | gaming |
DESKTOP-D249KD4 | 100.110.224.88 | current live single-writer authority (UsageGovernorAdmitAuthority scheduled task, serve-admit :47610); ledger C:\Users\AndrewJonesDev\.claude\usage-governor\agy-ledger.jsonl |
| Voter | greatroom |
ANDYGREATROOMPC | 100.69.90.95 | this box |
| Voter | streaming |
DESKTOP-SCDT9HI | 100.78.38.60 | |
| Observer | bedroom |
ANDREWBEDROOMTV | 100.85.245.95 | least stable; runs the stateless forwarder only (raftPort 0) |
- All 4 boxes: .NET 10 runtime present, fleet key at
~/.claude/fleet-dispatch.key, fleet-dispatch listener reachable and elevated (admin=True verified on the 3 remotes). - Remote execution:
FleetDispatch.exe send --target <PC> --kind pwsh --wait(payload = PowerShell). - Build artifact:
%OneDrive%\PC-Bootstrap\_shared\usage-governor\ug-raft-a410dd5.zip(publish of usage-governor mastera410dd5, which contains the gated Raft workc091939; SHA2567412953E704EAEE92484BD45D938DCC77A6915ECCAE070D944B4BC029AE031BC). - Ports — real: raft TCP 47620, admit HTTP 47621. Shadow drill: 47630/47631
(separate
UG_BASE_DIR=%USERPROFILE%\.claude\ug-raft-shadow, so the live ledger and live config are untouched by the drill).
Non-negotiable correctness requirements¶
- Identical
agypolicy config on all 3 voters.AgyRaftStateMachine.ApplyAsyncre-runsAgyPolicy.EvaluatePoolGateon EVERY node from its own config + ledger view; divergent budgets/floors would make nodes book different outcomes for the same committed command. At flip time, gaming's liveagyconfig block is the source of truth and is copied (with the raft block added,admitEndpointremoved) to every voter. - Identical pre-Raft ledger history on all 3 voters. Policy totals read the ledger file, so voters must start from byte-identical copies of gaming's live ledger, SHA-256 verified, taken AFTER the old authority is stopped (no writes in flight).
- Order of the flip on gaming: stop+disable
UsageGovernorAdmitAuthorityFIRST, then edit config (raft.enabled=true) — serve-admit and serve-raft refuse to coexist (mutual-exclusion guard, both directions), and the ledger snapshot must be quiescent. - Firewall posture: Windows inbound default is Block, so ONE scoped Allow rule per port is the whole story — raft TCP allowed only from the other two voters' tailnet IPs; admit HTTP allowed only from the tailnet CGNAT range (100.64.0.0/10). This closes RAFT-BLOCKERS #3's deploy gate (transport auth deferred; port reachability restricted to exactly the peers instead).
Phase 1 — permanent firewall rules (per voter; drill + real ports in one pass)¶
For voter X with peer voter IPs P1, P2 (run elevated, via dispatch):
# raft TCP: only the two peer voters may connect
New-NetFirewallRule -DisplayName "ug-raft-peers-in" -Direction Inbound -Action Allow `
-Protocol TCP -LocalPort 47620,47630 -RemoteAddress <P1>,<P2> -Profile Any
# admit HTTP: tailnet only
New-NetFirewallRule -DisplayName "ug-raft-admit-in" -Direction Inbound -Action Allow `
-Protocol TCP -LocalPort 47621,47631 -RemoteAddress 100.64.0.0/10 -Profile Any
Bedroom (observer) gets only the admit-HTTP rule (it has no raft port).
Phase 2 — shadow cluster¶
Per voter, via dispatch (elevated listener):
- Unzip
ug-raft-a410dd5.zip→%USERPROFILE%\.claude\ug-raft-shadow\bin\(SHA-256 verified against the value above after OneDrive sync). - Write
%USERPROFILE%\.claude\ug-raft-shadow\config.json:
{
"agy": {
"geminiBudget5hWeighted": 10000000,
"geminiBudgetWeeklyWeighted": 100000000,
"raft": {
"enabled": true,
"nodeId": "<gaming|greatroom|streaming>",
"voting": true,
"raftBind": "0.0.0.0:47630",
"httpBind": "+:47631",
"peers": [
"gaming=100.110.224.88:47630:47631",
"greatroom=100.69.90.95:47630:47631",
"streaming=100.78.38.60:47630:47631"
]
}
}
}
- Start detached:
$env:UG_BASE_DIR="$env:USERPROFILE\.claude\ug-raft-shadow"; Start-Process -WindowStyle Hidden "$env:UG_BASE_DIR\bin\UsageGovernor.exe" -ArgumentList "serve-raft". - Verify each node's
GET http://<ip>:47631/healthanswers andGET /raft-statusshows exactly one leader cluster-wide.
Phase 3 — the drill (the RAFT-BLOCKERS #4 scenario on real machines)¶
- Baseline admit through the leader (bearer = fleet key; use the shadow-config
agy-admitfrom this box) → allowed; verify the spend line lands in all 3 shadow ledgers (%USERPROFILE%\.claude\ug-raft-shadow\agy-ledger.jsonl) with the same raftIndex. - Partition the leader box (temporary block rules ON THE LEADER, both directions — outbound default-allow means the cut needs explicit blocks):
New-NetFirewallRule -DisplayName "ug-raft-drill-cut-out" -Direction Outbound -Action Block `
-Protocol TCP -RemotePort 47630 -RemoteAddress <P1>,<P2> -Profile Any
New-NetFirewallRule -DisplayName "ug-raft-drill-cut-in" -Direction Inbound -Action Block `
-Protocol TCP -LocalPort 47630 -RemoteAddress <P1>,<P2> -Profile Any
- Assert: admit against the isolated leader's :47631 → refused (raft fail-closed mode, and nothing booked anywhere for that attempt); the 2 majority voters elect a new leader and an admit against them → allowed.
- Heal:
Get-NetFirewallRule -DisplayName "ug-raft-drill-*" | Remove-NetFirewallRule. - Assert: old leader rejoins as follower; all 3 shadow ledgers converge to the same raftIndex set (baseline + during-partition admits; the isolated attempt appears NOWHERE).
- Tear down: kill the 3 shadow processes. Shadow dirs kept for the record.
Go/no-go: every assertion in step 3 + 5 must hold, verified by ledger artifact read off each box — not by HTTP responses. Any failure = NO FLIP, collect logs, report.
Phase 4 — the flip (only after a green drill)¶
- Gaming:
Stop-ScheduledTask UsageGovernorAdmitAuthority; Disable-ScheduledTask ...then SHA-256 the live ledger and copy it + the liveagyconfig block to%OneDrive%\PC-Bootstrap\_shared\usage-governor\(migration payload). - Greatroom + streaming: after sync, hash-verify and install the ledger copy at
%USERPROFILE%\.claude\usage-governor\agy-ledger.jsonl(backing up any existing file first). - All 4 boxes: merge into live
~/.claude/usage-governor/config.json: gaming'sagypolicy values,admitEndpointREMOVED, plus the real raft block (ports 47620/47621, peers list withbedroom=100.85.245.95:0:47621included,voting:false+ observer role on bedroom). - Unzip build →
%USERPROFILE%\.claude\usage-governor\bin-raft\on all 4; register scheduled taskUsageGovernorRaftAuthority(at startup, run as the box user, hidden) runningserve-raft; start it. - Verify: one leader on
/raft-status; realagy-admitfrom this box books exactly once on all 3 voters; observer forwards. - Watch the first hour of admits (each box's agents hit it organically).
Rollback (any step)¶
- Stop + disable
UsageGovernorRaftAuthorityeverywhere. - Re-enable + start
UsageGovernorAdmitAuthorityon gaming (its ledger was never moved — the copies went outward; worst case replay-safe because raft entries carry AdmitId/RaftIndex). - Restore
raft.enabled=false+admitEndpoint=http://100.110.224.88:47610in every box's config. - The firewall Allow rules are harmless to leave; drill cut rules must be removed (step 4 heal).
Execution log (2026-07-17 evening, WT-902b)¶
Phase 1+2 (shadow): setup dispatched to 3 voters ~18:56 CDT — zip hash verified on each, scoped
firewall allows applied, shadow nodes started. Cluster elected immediately (leader=gaming, term 8,
all 3 agree). Baseline admit via real client → allowed; spend|baseline-1|idx=2 identical on all 3
shadow ledgers.
Phase 3 (drill): GREEN.
- 18:57:55 partition applied on gaming (block rules, both directions, port 47630).
- Isolated live leader answered the authed direct admit with
raft: gaming is not leader and no leader is currently known; retry another peer — genuine Raft
fail-closed, nothing booked.
- Majority re-elected around it (term 8→9, streaming) and served during-partition.
- 18:59:13 healed; gaming rejoined as follower; final term 13, one leader.
- Convergence: all 3 shadow ledgers = {baseline-1@idx2, during-partition@idx6};
asked-in-minority present NOWHERE. Exactly-once held on real machines.
- Finding (availability, not correctness): AdmitViaPeersAsync treats a live node's "no leader
known" answer as final for the attempt — with the isolated node first in the peer list the client
fails closed even though the majority is serving. Money-safe; follow-up ticket recommended
(skip-to-next-peer when a node reports no leader and unasked peers remain).
Phase 4 (flip): DONE.
- Old authority UsageGovernorAdmitAuthority on gaming STOPPED + DISABLED ~19:05 CDT. Live ledger
snapshot: 23 lines, SHA256 EC0174B4…F38, distributed via OneDrive and hash-verified before
install on greatroom + streaming (both installed byte-identical).
- Gaming's live config had NO agy block → policy parity across voters = AgyConfig defaults; each
box's config gained the real raft block (47620/47621, bedroom observer :0:), admitEndpoint
removed, original config backed up as config.json.pre-raft.bak.
- UsageGovernorRaftAuthority scheduled task (serve-raft, AtStartup, S4U, RunLevel Highest,
restart 3×1min) registered + started on all 4 boxes; binaries at ~\.fleet\usage-governor-raft\.
- INCIDENT: greatroom's first serve-raft process wedged (accepted TCP on 47620/47621 but never
answered — HTTP hung, raft handshakes never completed), which poisoned every election round:
gaming/streaming climbed 150+ terms with no winner. Kill + task restart fixed it; cluster elected
within seconds of the healthy restart. Watch item: if a node wedges again, restart its task —
and consider a health-probe watchdog on the task.
- Post-flip verification: one leader; real agy-admit from greatroom → ALLOWED with the migrated
history counted (weekly 83,384.5 weighted from the 23 pre-raft lines); admit via bedroom
observer → ALLOWED; ledgers on all 3 voters = 25 lines, identical, flip-verify-1 and
via-observer-1 each booked exactly once.
- Watch item: term churn observed in bursts (281→330 over ~15 min) settling to stable stretches —
consistent with sub-second leader stalls under host load against the 150–700ms election bands.
Fail-closed blips only; recommend a follow-up ticket to raise prod election timeouts
(e.g. 4–10× the test bands — failover SLA here is minutes, not milliseconds).
Rollback state: old task still present (disabled) on gaming; every box has
config.json.pre-raft.bak and (greatroom/streaming) agy-ledger.jsonl.pre-raft.bak. Rollback =
runbook section above, ~2 minutes via dispatch.
Phase 6 (watchdog + peer-walk + retro fixes rolled out, 2026-07-18): the 3 follow-up chips
from phase 5 (wedge watchdog, E3 retro review, client peer-walk) plus a peer session's flaky-test
fix all committed real work, but 4 of those 5 commits — plus a further "harden watchdog per
council review" pass — never got PUSHED from the ug-wt-raft worktree (found + recovered: the
hardening pass was sitting fully written but uncommitted on disk). All landed on usage-governor
master 9c5ca6e (one merge conflict in GovernorPaths.cs, purely additive, both sides kept;
464/464 tests green). Rebuilt + rolled the REAL build (hash D27B0BF1…, confirmed
watch-raft: ok locally before shipping) to all 4 boxes. UsageGovernorWatchRaft (1-min
probe-and-cure task) registered and confirmed Ready on gaming/streaming/bedroom.
Known gap: greatroom's UsageGovernorWatchRaft task is NOT registered — creating a NEW
scheduled task with RunLevel Highest requires the caller to already be elevated, and neither
local Bash-tool PowerShell sessions on greatroom nor self-targeted fleet-dispatch (governor-gated
locally, was RED throughout this session) could provide that. Existing-task start/stop/run
(used for the authority binary swap) does NOT require caller elevation — only NEW task creation
does — which is why the authority update succeeded unelevated but this didn't. Greatroom's
UsageGovernorRaftAuthority itself IS on the current build and healthy; it just has no automated
wedge-recovery yet. Follow-up needed: one elevated PowerShell command on greatroom (copy the
Register-ScheduledTask block from Phase 2/rolling-update2.ps1's watchdog section), or wait for
the local governor to clear and retry via FleetDispatch.exe send --target ANDYGREATROOMPC.
Separately flagged: the worktree-identity CLI's documented worktree claim/release/list
sub-verbs are broken in the installed version (System.ArgumentException: Unexpected positional
argument) — real tool bug, hit while trying to release a stale cross-session worktree lock;
worked around by waiting for the lease to expire naturally rather than fixing the tool mid-deploy.
Phase 5 (post-flip hardening — watch item #2 closed): added
agy.raft.electionTimeoutBaseMs (all Raft timing scales linearly from it; default 150 = original
test tuning, byte-identical) — usage-governor master 5a365d3, full suite 273/273. Rolled the new
build + electionTimeoutBaseMs=1500 to all 4 boxes via dispatch (hash-verified zip
2383B7AE…). Before: 150ms bands churned 79 elections in 10 min (term 378→457, each a
fail-closed blip). After: term 457 held FLAT across a 9-minute sample (0 elections); admit still
ALLOWED, migrated history intact. Watch items #1 (wedge watchdog) and #3 (client peer-walk
skip-to-next) remain open as follow-up tickets — neither is a correctness risk.
Watch item #1 closed — serve-raft watchdog¶
Install registers a one-shot watchdog every minute on every box. The verb safely no-ops when
agy.raft.enabled=false:
schtasks /Create /SC MINUTE /MO 1 /TN "UsageGovernorRaftWatchdog" /TR "$exePath watch-raft" /F
Deploy through fleet dispatch using the Phase 4/5 per-box install pattern, then verify with
Get-ScheduledTask -TaskName UsageGovernorRaftWatchdog. For a manual bin-raft deployment,
dispatch this after expanding the build (replace path for each box):
schtasks /Create /SC MINUTE /MO 1 /TN "UsageGovernorRaftWatchdog" /TR '"C:\Users\<user>\.fleet\usage-governor-raft\UsageGovernor.exe" watch-raft' /F
Alternative: invoke watch-raft after scan in the existing five-minute task. Keep it one-shot;
do not create a long-lived watchdog process. Dedicated one-minute task is primary deployment.
Watch item #3 remains open; watch item #1 is closed by FLT-49.