Skip to content

fix(agent): Windows helper lifecycle durability (RDS accumulation)#2520

Merged
ToddHebebrand merged 47 commits into
mainfrom
ToddHebebrand/helper-sessions-in-remote-desktop
Jul 16, 2026
Merged

fix(agent): Windows helper lifecycle durability (RDS accumulation)#2520
ToddHebebrand merged 47 commits into
mainfrom
ToddHebebrand/helper-sessions-in-remote-desktop

Conversation

@ToddHebebrand

@ToddHebebrand ToddHebebrand commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

Windows agent + helper lifecycle durability. Implements slices 1–5 of docs/superpowers/specs/2026-07-14-windows-agent-helper-lifecycle-durability-design.md, plus a remediation pass over that work.

The problem (from the spec): Windows terminal servers accumulate far more breeze-user-helper.exe processes than there are eligible sessions — dozens of SYSTEM-owned helpers on a server with ≤20 users. IPC admission was keyed only by SID with a 5-connection cap per identity; every SYSTEM helper shares S-1-5-18, so a terminal server exhausts that bucket after five. Rejected helpers stay alive in a reconnect loop, are invisible to reconciliation, and get replacements spawned every 30s.

The fix: admission is now keyed by Windows session and SID, so each RDS session gets an independent quota bucket; helpers are owned by a kill-on-close Job Object; and a single-instance guard enforces one full agent per machine.

⚠️ Not ready to merge — read before approving

  • test-agent-windows (required) will likely fail on first run. It is new on this branch and has never executed. 3 tests fail on real Windows for reasons that predate this branch (main fails identically): two authenticate as system role but the test process isn't SYSTEM, and TestNamedPipeSessionDetector asserts every WTS session has a username when session 0 legitimately has none. Needs a decision — see the plan doc.
  • Watchdog slice 6 is incomplete. Task 1 of 5 is done (refactor(watchdog): return structured recovery outcomes). Its plan doc is committed; tasks 2–5 are unstarted.
  • -race on windows-latest is unverified — it requires cgo.

Remediation included in this branch

A full review of the original slice 1–5 work found 8 defects, each confirmed against source. Four of them independently reproduce the exact symptom the spec set out to fix — helper accumulation:

Fix Why it mattered
helperProcess.Alive()(bool, error) A GetExitCodeProcess failure read as "dead", so reserve handed out a duplicate helper. The sibling ownedPeerProcess.Alive() already returned an error — the two now match, and all four call sites fail closed.
Recycle helpers that never reach IPC KillStaleHelpers was deleted with no replacement. It was precisely what killed the pre-auth-rejected helpers the spec describes. trackedHelper.launchedAt was written twice and never read — the timeout hook existed but was never wired.
Failed termination now logs at Warn It logged at Debug; the default level is info, so a failed kill left no evidence at all while the broker forgot the helper and spawned another.
No fabricated exit codes Wait returns (-1, err) on failure; recording -1 as real marked a live helper exited, after which nothing ever terminated it.
Retry lifecycle bootstrap One transient WTSEnumerateSessionsW failure at boot left the named pipe never created for the process lifetime — no remote desktop, no PAM, no helper IPC — while the device kept heartbeating healthy. The fail-closed refusal is preserved; only the recovery is added.
Refuse spawn for non-lifecycle roles Any role that wasn't exactly "user" took the SYSTEM-token branch, including a zero-value HelperKey. Unreachable today only by data-flow accident.
Restore 5 deleted security-gate cases main had 10 cases in TestRoleIdentityRejection; this branch had 7. Mutation testing confirmed 4 gates shipped green when deleted, including the session-0 sentinel from the #1009 fail-closed fix.
Windows CI auto-discovers packages The hardcoded package list omitted ./internal/eventlog — its tests ran on no platform at all (the Linux job skips them via build tag). Nine other packages were also omitted.

Verification

  • go test -race -count=1 ./... (agent, darwin): green
  • go build + go vet: clean on darwin and GOOS=windows
  • Real Windows Server 2022: builds and runs natively. All 14 new tests pass. Zero regressions — the failure list is byte-identical before and after this remediation, and main fails identically.

Known gaps

Resolved since this PR was opened

Full detail and reasoning: docs/superpowers/plans/2026-07-14-helper-lifecycle-review-remediation.md.

Fixes

Closes #2536 — RDS: helpers fail to spawn on an RD Session Host (Job Object breakaway). Fixed and validated live on a 21-session RDS host.
Closes #2537 — Windows: multiple breeze-agent.exe instances can run at once. The single-instance guard is new on this branch; before/after reproduced on real Windows (main runs 2, branch refuses the 2nd).

Related follow-ups (not closed here): #2530 (bounded ownership retention), #2523 (Windows CI test debt), #2531, #2532.

🤖 Generated with Claude Code

Todd Hebebrand and others added 30 commits July 14, 2026 12:56
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 15, 2026

Copy link
Copy Markdown

Deploying breeze with  Cloudflare Pages  Cloudflare Pages

Latest commit: 6bcf403
Status: ✅  Deploy successful!
Preview URL: https://d4fd00e6.breeze-9te.pages.dev
Branch Preview URL: https://toddhebebrand-helper-session.breeze-9te.pages.dev

View logs

Todd Hebebrand and others added 12 commits July 14, 2026 22:54
Add an OS-neutral, fakeable Windows recovery state machine behind
windowsRecoveryBackend/watchedProcess so transition ordering is testable
on any host without importing golang.org/x/sys/windows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the fail-closed forced-recovery stub with the verified state
machine: validate -> terminate -> process exited -> (Stopped -> start |
SCM already restarting -> observe) -> Running with a new live PID.

Forced recovery never uses RecoveryRequest.StateFilePID for a
destructive action. It re-queries the SCM PID, proves the image and
liveness of the handle, and revalidates the PID immediately before the
kill, so a recycled PID cannot get an arbitrary process terminated.
Every uncertainty (config/query failure, open failure, image mismatch,
PID drift, exit timeout, unsettled SCM, same-or-dead new PID) fails
closed to a terminal failover disposition rather than looping on the
next attempt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the placeholder Windows osServiceController with concrete adapters
that satisfy windowsRecoveryBackend and watchedProcess, and wire the verified
recovery state machine into NewRecoveryManager on Windows. Forced recovery is
now reachable in production for the first time.

The backend reads SCM state/PID via mgr.Service.Query and the configured image
via mgr.Service.Config + DecomposeCommandLine/FullPath. Process handles are
opened with OpenProcess and verified by QueryFullProcessImageName before
TerminateProcess, so the state-file PID is never used for a destructive action.
Unrecognized SCM states, out-of-range PIDs and unexpected wait results fail
closed rather than being mapped onto a state the machine would act on.

Waits poll WaitForSingleObject in 100ms slices so an SCM stop interrupts an
in-flight exit wait, and handle operations serialize so Close cannot race an
in-flight query or wait.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the bool-based recovery dispatch in the watchdog main loop with the
structured RecoveryRequest/RecoveryResult flow from Tasks 1-4.

- recoveryJournalFields() renders intent, action, disposition, phase,
  initial/final state, elapsed, failure class, and action-taken. State-file PID
  and old/new SCM PIDs stay three distinct keys: the state-file PID is a stale
  hint, the SCM PIDs are what the service manager reported. Conflating them
  would erase the evidence that recovery never acted destructively on the hint.
- shouldVerifyRecovery() fails closed — only an error-free VerifyHeartbeat
  disposition enters heartbeat verification. Zero-value, errored, and unknown
  dispositions cannot read as "recovered". Terminal dispositions enter failover
  immediately via the existing EventRecoveryExhausted transition.
- A runCtx cancelled from independent forwarder goroutines (signal or SCM stop,
  cause-tagged) threads through every recovery request, including the
  failover-dispatch helpers. An SCM stop now aborts in-flight recovery waits
  instead of holding the service in STOP_PENDING for the recovery deadline.
- Failover commands map to explicit intents via failoverRecoveryIntent(), so
  start_agent can never select the ladder's forced attempt 2.

Heartbeat-based post-restart verification remains the application-health
success criterion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes slice 7's rollout-notes deliverable from the helper lifecycle
durability design.

Records one finding that contradicts the spec: the design assumes rollout
"beginning with RDS hosts and a Windows workstation cohort", but no cohort
or ring control exists. agent_versions.isLatest is a single global boolean
per (version, platform, arch, component), so promotion is all-or-nothing.
Staging has to be done by hand — install on chosen hosts, leave isLatest on
the old version, observe, then promote.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…al check

StopAcceptingAndWait closed unpublished connections while their handler
goroutines were still running. handleConnection reads the raw pipe handle
via ipc.GetPeerCredentials, and go-winio's Fd() reads win32File.handle with
no synchronization while Close() writes it. -race on windows-latest caught
this in TestNamedPipeListenAndAccept; it is intermittent, so it passed the
previous run.

The consequence is worse than a torn read. GetPeerCredentials feeds
GetNamedPipeClientProcessId -> OpenProcess -> token -> SID, so a handle
closed mid-call and reused by the OS for another object could attribute a
connection to the wrong process. This is the authentication path.

Cancel unpublished connections with a deadline in the past instead. It
cancels pending IO immediately, never touches the handle, and leaves the
handler as sole owner of closing the connection.

A deadline, unlike a closed handle, can be overwritten: the handler armed
its own handshake deadline right after the shutdown cancelled it, undoing
the cancellation. So arming now happens under acceptMu via
armHandshakeDeadline, which fails closed once acceptStopped is set. Without
that ordering StopAcceptingAndWait stalls for a full HandshakeTimeout --
TestBrokerStopAcceptingAndWaitUnblocksStalledPreAuthConnection catches it
(verified by mutation: dropping the check reproduces the stall).

Introduced by d413bde on this branch; not present on main.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces 'tracked separately' with the issue that records the root cause of
each of the six failures, so the narrowed job list explains itself.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The comment in TerminateHelperKey referenced 'the follow-up noted in the
remediation plan'; that follow-up is now filed as #2530 (bounded ownership
retention for scheduled helpers). Comment-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s denied

Found by running this branch as the real BreezeAgent service on a Server 2022
RD Session Host with 6 concurrent RDP sessions (#2536). On a real terminal
server every session's processes live in the session's own job, so the helper
cannot join the agent's Job Object and AssignProcessToJobObject returns
ERROR_ACCESS_DENIED. The previous code treated assignment as mandatory and
fail-closed, so NO helper spawned for any RDS session and reconcile looped
forever. This is a regression this branch introduced: main has no Job Object,
so on main helpers spawn on RDS (then accumulate via the old SID-cap bug).

Fix:
- job.Assign is now best-effort. On denial the helper still spawns and is
  tracked/terminated via its process handle (logoff, shutdown, reconcile all
  terminate by handle). Only OS-enforced KILL_ON_JOB_CLOSE cleanup after an
  agent *crash* is lost; the single-instance guard + reconcile reclaim orphans.
- helper creation now attempts CREATE_BREAKAWAY_FROM_JOB (with a no-flag
  fallback) to keep full job ownership where the environment allows it.
- the spawn diagnostic records jobOwned so the field can be observed.

Validated live on the RDS host: before, 3 helpers (console only), sessions 2-7
looping on job-assign-denied; after, 14 helpers (2 per session x 7 sessions),
stable across reconciles, every SYSTEM helper admitted with a distinct
session-scoped key windows:S-1-5-18:session:1..7 (12 of 14 via best-effort
handle ownership, 2 with full job ownership). No accumulation, no rejections.

Test: TestWindowsHelperSpawnerSpawnsWhenJobAssignDenied asserts a denied assign
still spawns (resumed, not terminated, jobOwned=false) — the exact gap the old
fakeHelperJob (Assign always nil) hid, so 20-session unit tests passed while a
real RDS host failed. The former fail-closed-on-assign test is removed as it
encoded the reversed contract; resume-failure fail-close is unchanged.

Refs #2536

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ToddHebebrand
ToddHebebrand marked this pull request as ready for review July 16, 2026 04:08
@ToddHebebrand
ToddHebebrand merged commit e4989ea into main Jul 16, 2026
43 checks passed
@ToddHebebrand
ToddHebebrand deleted the ToddHebebrand/helper-sessions-in-remote-desktop branch July 16, 2026 04:26
ToddHebebrand added a commit that referenced this pull request Jul 16, 2026
Technical-docs sweep for the **v0.96.0** release (range
`v0.95.1..main`). Companion to the tagged release; no code changes, docs
+ docs-review mapping only.

## Pages updated
- **TD SYNNEX AI tool** — `features/mcp-server.mdx`, `features/ai.mdx`,
`features/distributor-integrations.mdx`: new
`lookup_distributor_product` (Tier 2 / `ai:write`; live EC Express
price+availability; reseller cost redacted for org-scoped callers,
error+no outbound call when no partner in context) (#2559)
- **M365 customer Graph-read host config** — `deploy/environment.mdx`
new "Customer Microsoft 365 Graph-read consent (optional)" section: 9
env vars, **all required only when
`M365_CUSTOMER_GRAPH_READ_ONBOARDING_ENABLED=true`** (dark by default);
`deploy/production.mdx` optional `m365-graph-read-executor` sidecar
note; `features/identity-integrations.mdx` pointer (#2495, #2511)
- **Quotes** — `features/quotes.mdx`: cloning, change-customer-on-draft,
per-table subtotals, customer bill-to "Prepared for" block, optional
send message, click-to-zoom, Pax8-order-on-acceptance cross-link (#2535,
#2584, #2549)
- **Backups** — `backup/overview.mdx` (per-device "Run backup now",
timezone-correct next-run, `breeze-backup` helper now bundled by
shell-script installs), `backup/restoring.mdx` (System image badge,
mode+mtime + system-image restore), `backup/verification.mdx` (integrity
= size+SHA-256 vs manifest, real corruption detection) (#2558, #2564,
#2581)
- **Enrollment keys** — `agents/enrollment-keys.mdx`: required Site
selector on create form (#2557)

## Mapping / tracker
- `scripts/docs-review/mapping.json`: repoint pax8 →
`distributor-integrations.mdx`; add `pax8Orders`, `pax8Order*`,
`quoteToPax8Order`, `aiToolsCatalog`, `m365ControlPlane/**`,
`runtimeConfig.ts`→environment,
`m365CustomerGraphRead`/`m365ConsentCallback`,
`apps/m365-graph-read-executor/**`, quote pdf/email services,
`agent/scripts/install/**`
- `scripts/docs-review/last-reviewed.json`: bumped to `20276393e`
(2026-07-16)

## Verification notes
- The M365 consent **callback URL derives from `PUBLIC_URL`** in the
API; the `..._CALLBACK_URL` env var is consumed by the **executor app**,
not the API — documented accordingly (not as dead config).
- The `m365-graph-read-executor` sidecar is **published (GHCR) but not
wired into the tracked compose files** — documented as such, not as a
turnkey service.
- `apps/docs` `astro build` passes.

Intentionally **skipped** (no admin/integrator doc surface): versioned
extension contract #2548 (developer-only SDK), Windows helper RDS
lifecycle #2520 (internal agent fix), backup execution/result-recording
internals #2554/#2556.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Todd Hebebrand <todd@lanternops.io>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant