Repository navigation
fix(daemon): keep a previous daemon version the app could not reach instead of reading it as gone - #23959
Conversation
…nstead of reading it as gone At startup a previous daemon version that missed a 1-second connection check was dropped from the version list for the whole run. Session lookup then answered "absent", the restored tab certified its live agent dead, dropped its saved session and resumed a second copy of the same conversation on top. One endpoint probe now answers live / unverifiable / exited and every caller uses it; the yes/no probe is deleted. "Exited" needs the endpoint gone, or a refusal while the recorded daemon process is gone: macOS refuses a live listener whose accept queue is full. The census keeps unverifiable versions (and logs each), so a restored pane shows the shipped "left the saved session unchanged, Retry" state instead of starting over. An exact match on the saved session identity still resolves panes whose session lives in a version that answered. Also: - attach-only failures that never heard the owner answer (timeouts, lost connections, refusals without proof of exit) now read as owner-unverified, so the renderer keeps the pane's binding instead of starting a fresh session; - a version that exits mid-run lists as empty, re-derived on every listing; - kills and writes for a session with no route resolve its owner instead of asking the current daemon, whose "not found" read as already gone; - Manage Sessions kills the clicked row by protocol version and incarnation; - cleanup, the replacement health-check grace and the self-shutdown wait no longer read a timed-out probe as the daemon exiting. Part of STA-2018.
|
| write(id: string, data: string): boolean { | ||
| return this.adapterFor(id).write(id, data) | ||
| return this.knownOwnerFor(id)?.write(id, data) ?? false |
There was a problem hiding this comment.
Live pane input is dropped When the same session ID is live in the current and a legacy daemon, a later complete inventory removes its cached route. This write then finds two possible owners and returns
false, even for an already attached pane. The input path does not surface that failure, so the user’s keystrokes silently disappear. Preserve or recover the pane’s established owner before refusing its input.
Knowledge Base Used:
| function isContactFailure(error: unknown): boolean { | ||
| return ( | ||
| error instanceof DaemonProtocolError || | ||
| isDaemonGoneError(error) || |
There was a problem hiding this comment.
Daemon rejections become retry prompts A daemon can explicitly reject an attach, for example with “Invalid token” during the hello exchange. That rejection is a
DaemonProtocolError, so this check treats it as lost contact. The pane then shows owner-unverified and offers Retry instead of explaining the rejection; retrying with the unchanged token cannot fix it. Keep daemon-answered errors distinct from timeouts and connection loss.
Knowledge Base Used: Terminal runtime and interaction
There was a problem hiding this comment.
ℹ️ No critical issues — one cross-cutting tradeoff worth confirming.
Reviewed changes
- Three-valued endpoint probe (
daemon-endpoint-verdict.ts):probeDaemonEndpointreturnslive/unverifiable/exited, provingexitedonly on a missing endpoint or a refusal while the recorded process is gone;attachOnlyFailuremaps attach errors toTerminalHostGoneError(proven) orTerminalSessionOwnerUnverifiedError(uncontactable). The old yes/noprobeDaemonSocketis deleted andisDaemonEndpointGoneErroris demoted toisDaemonEndpointConnectRejection. - Census keeps unverifiable versions (
daemon-legacy-adapters.ts): onlyexitedversions are dropped and reclaimed;unverifiableones are retained and logged. - All app-side readers follow the rule: attach (
daemon-pty-session-spawn.ts), inventory (daemon-pty-session-inventory.ts), router kill/write (daemon-pty-router.ts), self-shutdown wait (daemon-protocol-cleanup.ts), replacement grace (daemon-replacement-preflight.ts), adoption (daemon-out-of-process-launcher.ts), and the removal of the local translation instable-owner.ts. - Exact saved identity (
daemon-session-owner-resolution.ts): an exact persisted-incarnation match in a version that answered is accepted even when the inventory is incomplete. - Manage Sessions kill by clicked row (
pty-management.ts, renderer):killOnenow takes{sessionId, protocolVersion, incarnationId?}.
I traced the two riskiest cross-cutting surfaces and found them safe: the removed stable-owner.ts translation still classifies correctly on the paired-runtime path (classification runs host-side in attachOnlyFailure before the RPC, and the renderer keys off the terminal_pane_owner_unverified message), and the removed current-daemon fallback in router.write/shutdown does not drop input for a routed pane.
ℹ️ Keeping an unverifiable version makes every aggregate daemon listing fail closed
DaemonPtyRouter.listProcesses joins its adapters with Promise.all, and an unverifiable adapter's listProcesses rethrows rather than returning [] (daemon-pty-session-inventory.ts only treats a proven exit as empty). So while any retained version is unverifiable, the router's whole listing rejects. That is the deliberate fail-closed contract for stop/liveness flows, but the census change widens its blast radius: a version that previously would have been dropped now sits in the router and fails those flows until it answers or is proven exited.
Technical details
# Keeping an unverifiable previous version fails closed every aggregate daemon listing
## Affected sites
- `src/main/daemon/daemon-pty-session-inventory.ts:97-104` — `listProcesses` rethrows unless `connectFailureProvesExit` returns true (status `exited`); `unverifiable` rethrows.
- `src/main/daemon/daemon-pty-router.ts:215-222` — `listProcesses` is `Promise.all(allAdapters().map(...))`, so one unverifiable adapter rejects the whole call.
- `src/main/daemon/daemon-legacy-adapters.ts:52-64` — the census now keeps an `unverifiable` version.
- `src/main/runtime/unstopped-pty-verification.ts:38-58` — a rejected listing becomes `status: 'unverifiable'`, which blocks worktree removal (`force` required).
- `src/main/ipc/pty/runtime/kill.ts:279-297` — `verifyPtyStopped` rejection marks the PTY liveness unverifiable and returns `false` for the close.
- `src/main/daemon/daemon-provider-state.ts:99-116` — `listLiveDaemonPtyIds` returns `null` (via `allSettled`), so startup Codex pane reconciliation is skipped.
- `src/main/daemon/daemon-pty-router.ts:61-64` — `supportsAgentSessionClaims()` returns `every(...)`, so a retained old (pre-v26) version flips it false.
## Required outcome
Decide whether this widened fail-closed surface is acceptable for the duration a version is
unverifiable. In the target scenario (a startup stall) it is transient because the version soon
answers; it is persistent only for a version kept unverifiable by a recycled-stale-pid record, which
the PR body accepts. If intended, a line in the PR body / `daemon/AGENTS.md` would make the tradeoff
explicit; if not, the listing needs to distinguish "a version I could not ask" from "a version that
answered and holds nothing".
## Open questions for the human
- Is blocking worktree removal (until Force) while an unrelated previous version is unverifiable the
intended cost, or should the per-adapter listing tolerate an unverifiable peer for non-destructive
reads?
- Should `supportsAgentSessionClaims()` treat an unverifiable legacy adapter as absent rather than as
a veto?DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
…ous version cannot be reached A tab not reattached this run has no route, so its close resolved the owner without an identity and was refused whenever any previous version was kept unverifiable, even when the session lived in a version that answered. Local kills now carry this run's incarnation, else the pane's saved one, and the router resolves the owner by it.
…win over the probe timer After a stall, the event loop runs expired timers before polling I/O, so the 1 s check reported a timeout for an endpoint that had already accepted. The timeout is now reported one turn later, after that poll.
…dates this boot A crashed version leaves its socket file and pid record behind. Once the recorded pid names another live process, or one this user may not signal, the version stayed unverifiable on every launch until that process exited. A record started before the last boot cannot be that process.
…ged-lines gate passes
| if (liveness.status === 'live' && startedBeforeThisBoot(record.startedAtMs)) { | ||
| // Why: whatever answers to that pid now, it is not a process recorded before the last boot. | ||
| return { status: 'exited' } |
There was a problem hiding this comment.
Clock jump hides live daemon If the wall clock jumps forward by more than five minutes after a daemon writes its PID record, and that live daemon's full accept queue refuses connections, this check can report that it exited. Startup then drops the daemon version from its census, so a restored pane can treat its running agent as gone and start a second copy.
Knowledge Base Used: Terminal runtime and interaction
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
Since the prior pullfrog review at e20f86a4, eight commits land on this branch:
- Saved-identity shutdown routing (
78b3730962):local-pty-shutdown-identity.tsresolves the pane's saved incarnation whenptyIncarnationByIdhas none, and it is threaded asexpectedIncarnationIdthroughshutdownProviderAndDetectExit→DaemonPtyRouter.shutdown→ownerFor(id, expectedIncarnationId)→DaemonSessionOwnerResolver.resolve. A tab close can now reach the version that owns the session even while another version stays unlistable. - Exact-incarnation match without a complete inventory (
daemon-session-owner-resolution.ts): an exact persisted-incarnation match in a version that answered is accepted even when another version stayed silent, on the argument that incarnations are random per session (session.ts:26). - Probe timeout yields to a completed connect (
daemon-endpoint-probe.ts): the timer now schedules itsunknownresult one event-loop turn later, so a connect the kernel completed during a main-thread stall is observed beforesettle('unknown'). - Boot-time exit proof (
daemon-endpoint-verdict.ts): a refused connection with a live recorded pid is read asexitedwhen the record'sstartedAtMspredates the current boot by more than the 5-minute tolerance, proving the pid was recycled. - Unverified-pane log (
stable-owner.ts): a pane left owner-unverified now logs[daemon] Keeping pane session <id> unverified.
I traced the shutdown routing end to end (renderer pty:kill → stopRendererOwnedPtyProcess → shutdownProviderAndDetectExit → router ownerFor), confirmed the passed expectedIncarnationId rides through opts to provider selection while the live ptyIncarnationById value still filters observed exits, and ran the changed suites locally (daemon-endpoint-probe, daemon-endpoint-verdict, daemon-legacy-census-unreachable, pty-kill-saved-pane-incarnation, daemon-pty-router, pty-management): 6 files, 89 tests pass.
ℹ️ PR description still states an exit-proof rule the boot-time commit narrows
The PR body's "Differences from the reference implementations" bullet ("Proof of exit uses only the signal-0 check (ESRCH), not the process start time… a stale pid record whose number now belongs to an unrelated process keeps that version unverifiable") and the Windows bullet ("A missing named pipe (ENOENT) is the only exit evidence… the probe uses process.kill(pid, 0)") no longer match the code. Commit 9a678369cd adds a second proof: a live recorded pid whose startedAtMs predates the current boot now reads as exited. Worth updating those paragraphs so the documented cost matches the heuristic that replaced part of it.
Technical details
# PR body exit-proof description predates the boot-time heuristic
## Affected sites
- `src/main/daemon/daemon-endpoint-verdict.ts:104-115` — `recordedDaemonVerdict` returns `{ status: 'exited' }` when `liveness.status === 'live'` and `startedBeforeThisBoot(record.startedAtMs)` is true; this is a new exit proof beyond the signal-0 check the description names.
- PR body sections "Differences from the reference implementations" (last bullet) and "Scope, platforms, mixed versions → Windows" — still assert signal-0 `ESRCH` / missing endpoint as the only exit evidence.
## Required outcome
The description's exit-proof and stale-pid-cost claims match the code after commit `9a678369cd`.
## Open questions for the human
- Does the Windows note need its own wording (e.g. a recycled pid record predating boot now also proves exit)?DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
…retires the saved binding Closing a split pane that never reconnected this run sent the pane-close intent first, so main deleted the pane's saved incarnation before the stop read it and refused the stop while a previous daemon version was unverifiable, leaving the session running with no pane. The stop is now sent first, as whole-tab close already does.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
Since the prior pullfrog review at 23298572fa, one commit lands on this branch:
- Stop a closed split before its close intent retires the saved binding (
use-terminal-pane-close-actions.ts,a2493914b8): the split-pane close path now issuesretireUnboundIpcTerminalPane(which dispatchespty:kill) ahead ofcommitTerminalSurfaceClose, matching the tab-close order interminal-tab-close.ts:39vs:251. Main's kill handler readslocalPtyShutdownIncarnationfrom the saved binding before its first await (renderer-kill.ts:98-100), so thecloseTerminalSurfaceIPC that deletes that binding must not be dispatched first. - New regression test (
pending-split-close.test.ts): assertswindow.api.pty.kill's invocation order precedescloseTerminalSurface. I reverted just the ordering and confirmed the test fails, then passes with the commit — it is real coverage, not theatre. - Comment only (
renderer-kill.ts): the "read before any await" rationale now names the tab or pane close intent.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
Review status: ready for your merge decision (not merged)The problem. On a slow startup (the app's main thread stalls about a second while it checks a terminal service left over from the previous Orca version), Orca decided that version's sessions were gone. The tab marked the running agent dead and started a second copy of the same conversation while the original kept running unseen, pinning the old service forever. Manage Sessions' Kill could also end the wrong one of the two identical rows this left behind, including the open tab's own session. What changes for you. After a slow startup the tab reconnects to the agent that was already running. If the old version really cannot be reached, the tab shows the existing "Orca left the saved session unchanged … Retry" message, and Retry recovers once it answers. No second copy is started. Kill in Manage Sessions ends exactly the row you clicked. Closing a tab or a pane still ends its session while another old version is unreachable. Nothing is killed automatically; orphans from before this PR stay until you end them. Fixed during review (on top of the original commit).
Deferred (stated in the body).
Verified.
Not verified. Windows and Linux beyond CI unit tests, SSH and WSL, packaged builds, the path where the check still times out at the new head (the stall now always reads as connected), and closing a tab while two sessions share its id. |





ELI5
When Orca starts on a busy Mac, it can take more than a second to say hello to the terminal service left over from the previous Orca version. Today Orca then decides that old service is gone, tells your agent tab its conversation is dead, and quietly starts a second copy of the same conversation while the original keeps running where nobody can see it. After this PR Orca only calls the old service gone when it can prove it. A hello that arrived during the stall now counts, so the tab simply reconnects. If Orca really cannot tell yet, the tab keeps its saved session and shows the existing "Orca left the saved session unchanged … Click Retry" message instead of starting a second copy.
The problem
What users see: after Orca updates, agent sessions from the previous version keep running invisibly and pile up (one report: ~46 hidden Claude processes against 8 visible sessions and ~20 GB of swap). Worse, on
maina restored agent tab can start a second copy of the same agent conversation (claude --resumeof the same transcript) while the original keeps running with no tab.Why it happens: at startup Orca checks each previous terminal-service version (daemon
v1…v35) with a 1-second connection check. The check returned the same "no" for three different facts: the socket file is gone, the connection was refused, or the 1-second timer fired first. A "no" dropped that version from the version list for the whole run (daemon-legacy-adapters.ts).Measured trigger: the kernel accepts a Unix-socket connection even from a daemon that is stopped, so a busy daemon does not cause this. What causes it is the app's own main thread stalling about a second across the check (a loaded Mac at boot). When the stall ends, Node runs expired timers before it looks at finished connections, so the timer wins against a connection that already succeeded.
What happened next: session lookup counted only the versions it still had, called its answer complete, and answered "absent". The tab took that as proof the agent had exited (
hostExitConfirmed: true), dropped its saved session, and started a fresh one with the pane's resume command. If every previous version was dropped, the bare current daemon answered "Session not found" for another version's session, with the same result. The original agent ran on with no tab, and it kept its old daemon alive across every later update.A second way the same mistake happened: on macOS a live daemon that is not accepting connections refuses every connection once 128 are queued (measured, and reproduced by a positive-control test here).
mainread any refused connection as "that daemon exited", both in startup cleanup and when a tab reattaches (stable-owner.ts), so a frozen daemon could also trigger the second copy.What changes for the user
The plan (this PR is step 2 of 4)
This PR is stacked on #16900 (base
brennanb2025/coercion-contract-design-r1).How it works
probeSocketConnect): when the 1-second timer fires, the check reports the timeout one event-loop turn later. A connection that finished during a stall is processed first, so it counts as connected. This removes the common trigger at its source.live/unverifiable/exited:probeDaemonEndpoint(daemon-endpoint-verdict.ts). It reuses the existing connect classifier, and every caller of the old yes/noprobeDaemonSocketnow uses it. The old probe is deleted.exitedrequires real proof: the endpoint is missing, or the connection was refused and the recorded daemon process is gone. "Gone" meansESRCHfrom the signal-0 check, no pid record (a daemon removes its own record as it exits), or a record that says the daemon started before this machine last booted. A refusal while the recorded process runs, a timeout,EPERMwithout the boot proof, or an unreadable record all read asunverifiable.daemon-legacy-adapters.ts): versions that areliveorunverifiablestay in the list, so the existing resolver answers "unknown", which produces the shipped owner-unverified state. Onlyexitedversions are dropped, and only they go through Preserve legacy daemon ownership when liveness is unavailable #16900's file reclaim. Each version kept is logged as[daemon] Keeping previous daemon vNN unverified: <reason>, and each pane left unverified as[daemon] Keeping pane session <id> unverified: no daemon proved its owner, so we can count how often this happens.daemon-session-owner-resolution.ts): a single exact match on the pane's saved incarnation (a random UUID per session) in a version that answered is accepted even when another version did not answer. A saved incarnation that is out of date can fail to match but can never match the wrong session. Sessions from before incarnations existed (protocol v1–v25) stay unverified, the cautious answer.daemon-pty-session-spawn.ts): a refused or missing endpoint maps to "the terminal daemon exited" only when the probe proves it, and to owner-unverified otherwise. The unproven check instable-owner.tsis removed.daemon-pty-session-inventory.ts): a version that exits mid-run lists as empty, based on proof, and nothing is stored. So one that goes away cannot leave lookups stuck on "unknown" for the rest of the run.daemon-pty-router.ts): a session with no route is resolved to its owner, no longer sent to the current daemon, whose "Session not found" read as "already gone". Local kills (tab close, pane close, runtime and CLI kills) pass the pane's saved incarnation, so a session in a version that answered is found even while another version is unreachable; SSH kills are unchanged. Absent from every version means gone; unknown throws an error that does not mean absence. A settled write (orchestration) to a session no version can be shown to own is refused (provider_unavailable), and a plain keystroke write to a session with no route is dropped, instead of either being handed to the current daemon.cleanupDaemonForProtocol) no longer deletes an old pre-v24 daemon's pid record or skips teardown after a timed-out check; the self-shutdown wait (waitForDaemonEndpointExit) no longer takes a timeout as exit; the health-check grace retry keeps waiting while the check times out, instead of moving on to killing a daemon whose session count is unknown (a refusal from a live process still reaches the existing kill quickly, as before). The launcher's last-chance adoption still adopts only a daemon that answers.What this deletes
probeDaemonSocket(the yes/no probe) and its separate timeout constant.continuethat turned a missed check into "that version is gone".isDaemonEndpointGoneErroras a verdict. It is renamedisDaemonEndpointConnectRejection, is now only a first filter, and the probe decides.Traced along the way (the "any other reattach error" path)
The renderer (
reattach-result-handler.ts, reached fromdeferred-session-reattach-connect.ts) answers any local reattach error other than owner-unverified by clearing the pane's saved session and starting over with the resume command. Errors from main arrive there as "no result":ipc-pty-connect.tsroutes them toonErrorand returnsundefined.For a single-pane tab, main puts the binding back (
workspace-session-terminal-binding-replay.ts), so the next attempt reattaches again rather than duplicating. For a split tab, the partial layout is written without that leaf's binding and nothing restores it for local panes, so the next spawn has no owner and starts fresh, orphaning the original.So that path makes the same mistake whenever the error is "could not reach it" (a timeout or a dropped connection). This PR fixes it at the source: an attach that never heard the owner answer now reports owner-unverified, which the renderer already handles by keeping the binding and offering Retry.
What still reaches that renderer path unchanged: a proven "daemon exited", ownership races (
terminal_pane_owner_changed/_conflict/_host_mismatch), local history I/O errors, and renderer-side exceptions in the.catchindeferred-session-reattach-connect.ts.Why this design
Differences from the reference implementations
killrequest takes no incarnation at any shipped version. Intended for this PR, since the fix must work against unmodified old daemons. The window between the two steps is small, because a closed tab's session ids are never reused.pid_missing, a start-time comparison there has one-second resolution against the daemon's own reported start (a slow-starting live daemon could read as "different process", which is this very bug), and a start-time read on Windows would fork PowerShell. The cost is below under "Known limits".Known limits (all fail toward "unverifiable", never toward a second copy)
mainfor a version that answered at startup and froze later. While that lasts, the Resource Manager shows its sessions error, a workspace delete asks for Force Delete, and theorca terminal createretry check reports the runtime unavailable. With the stall fix and the boot check, this now needs an old daemon that is genuinely alive but not answering. Step 3 (fix(terminal): list, stop, open and delete past a terminal-service version that does not answer #22046) lists what answered and marks the unverifiable version instead.Scope, platforms, mixed versions
ENOENT) is the only exit evidence from the endpoint. Busy pipes,EPERMand timeouts are unverifiable. No process enumeration and no PowerShell: the probe usesprocess.kill(pid, 0)andos.uptime(). Nothing indocs/reference/windows-daemon-host-relocation.mdis touched. An unverifiable version keeps its host folder pinned until it answers or exits.EAGAIN, which reads as unverifiable.Not in this PR (follow-ups)
degraded-daemon-pty-provider.ts) still sends kills for a session with no route to its local fallback provider, and resize and signals for a session with no route still go to the current daemon. Same class of mistake, rare.killStaleDaemonstill take a refusal as proof of death (endpointIsProvenDead). That affects replacing the current daemon, not the census.src/main/daemon/AGENTS.mdnow warns that a refusal alone is not proof outside the publisher.Visual Proof
Live run on a Mac (M4 Air) with an isolated profile: a v36 build holds a terminal running
sleep 100000, then a v37 build of each branch starts with a deliberate 1.5-second main-thread stall across the version check. The shell prints its own pid so you can tell the original from a copy.Before (base): the tab comes back as a NEW shell (pid 80695) while the original
sleepkeeps running in the old version with no tab.After: the same stall, and the tab reattaches to the ORIGINAL shell (pid 69964) with its
sleepstill running. One session.After, old version frozen (SIGSTOP): the tab keeps its saved session and offers Retry instead of starting a copy.
After Retry (old version resumed): the original scrollback and running
sleepare back.Manage Sessions with the duplicate the base build created (two rows, same id). Before: Kill on the second row (the old-version orphan) killed the first row instead, which was the open tab's own session, and the tab closed.
After: Kill on the second row ended exactly that session; the tab's own session stays and the tab keeps working.
Testing
Real sockets, no Electron, run with
vitest run --config config/vitest.config.ts:daemon-legacy-census-unreachable.test.tsruns real daemon servers for v35 and the current version. A previous app run leaves a live session in v35, and the connection check to v35 is forced to time out. On the base (Preserve legacy daemon ownership when liveness is unavailable #16900's head,e6b3d2392b3), five of its cases fail: the restore starts a second copy under the same id (with and without another previous version), still starts fresh when v35 answers after the stall, attaches a pane saved against the orphan to the other copy, and reads closing the stuck pane as "already gone". It also covers closing a current-daemon session that was never reattached while v35 is unreachable: killed with its saved identity, refused without one.daemon-endpoint-probe.test.ts: a real listener, the main thread blocked across the probe's timer; the connection that finished during the block wins. Fails without the timer change.daemon-endpoint-full-accept-queue.test.tsstarts a child listener that blocks its own event loop with a backlog of 1. Positive control: macOS returnsECONNREFUSEDfor it. It is kept as unverifiable; once the process is killed it reads as exited; and a killed listener whose record names a live process reads as exited only when the record predates this boot.daemon-endpoint-verdict.test.ts: the probe's results, and the mapping from attach failures to answers (timeouts and dropped connections become owner-unverified; "absent" and "killed" pass through).pty-kill-saved-pane-incarnation.test.ts: tab close, runtime kill and stop-and-wait pass the saved identity; panes that disagree pass none.Cleanup, grace retry and self-shutdown wait:
daemon-init-restart-sequence.test.tsanddaemon-init-wedged-daemon-grace.test.ts. Manage Sessions:pty-management.test.ts.Revert checks. Every behavior change was undone on its own and its tests went red. The per-version log line and the Manage Sessions row key have no test.
Tests updated to the new rule (each one pinned the old behavior): "does not trust persisted incarnation proof from an incomplete inventory" (owner resolution), "pins colliding unmapped legacy ids falling through to the current daemon" (router), a timed-out probe returning
cleaned: false(restart sequence). The router's test double no longer counts every listed session as attached, which had hidden the tab-close case above.Live QA on a Mac (M4 Air), isolated profile, plain
sleepshells: the screenshots above.Not verified here: Windows and Linux runs (CI covers the unit tests), SSH and WSL, packaged builds, and how often this happens in the field. The new log lines make that countable.
I manually tested these changes locally
Automated tests added/updated, or explained why not below
Linked Issue
Part of STA-2018. Orphans already on users' machines stay until the user ends them.
Checklist
N/Awith reasonAuthor: Brennan Benson (@BrennanKB5)