Skip to content

fix(desktop): preserve profile across worktree handoffs - #95045

Open
Tomenatore wants to merge 1 commit into
NousResearch:mainfrom
Tomenatore:fix/desktop-worktree-profile-attribution
Open

Tomenatore wants to merge 1 commit into
NousResearch:mainfrom
Tomenatore:fix/desktop-worktree-profile-attribution

Conversation

@Tomenatore

@Tomenatore Tomenatore commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Keeps a new Hermes Desktop session on the profile/backend that owned the session surface which initiated a Git worktree hand-off.

The failing sequence was:

  1. A named-profile session opens or creates a linked worktree.
  2. requestStartWorkSession() carries only path, draft, and openTab.
  3. The controller resets the selected session for the fresh draft, losing the only remaining owner identity.
  4. session.create falls back to mutable new-chat/active-gateway state and can run on default.

In the reproduced case, the named-profile WebSocket opened but sent no request; session.create ran on the default backend. The resulting row was present only in default state.db with profile_name = NULL, so its worktree cwd produced a default-profile auto-project and no named-profile glyph.

This change captures an immutable owner at the interaction boundary (a profile name or exact connection route), carries it through the worktree request and fresh-draft transition, and applies it before session.create. Main composers, session tiles, the worktree dialog, command-palette worktree entries, and project/open-folder entry points all preserve the appropriate owner.

Existing misrouted sessions are intentionally not migrated or restarted. This fixes future hand-offs without interrupting active work.

Related work

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • Add a normalized NewChatOwner boundary for profile names and exact connection routes.
  • Preserve unique connection-qualified rows/owner hints instead of collapsing them to a same-named profile on another connection; leave ambiguous claims unresolved.
  • Route regular session tiles through the behaviorally-tested owned-session dispatcher and persist exact routes on newly-created unlisted/openTab tiles.
  • Resolve a main composer from the main selected session (never the focused tile), and capture its owner before asynchronous worktree creation.
  • Capture folder/project and worktree-dialog owners before asynchronous pick/probe/refresh work can switch the active surface or gateway.
  • Carry owner identity through StartWorkSessionRequest and startWorkspaceSession into the fresh draft.
  • Route profile-only and connection-qualified session creation through the existing new-chat routing path.
  • Cover main/tiled composers, delayed worktree dialogs, command-palette worktree starts, and project/open-folder starts.
  • Leave existing rows and live sessions untouched.

How to Test

Regression before the production change:

routes a worktree handoff by its source session instead of a stale default draft
expected profile: "itb"
received profile: "default"

A mutation run that temporarily removed only the new owner application reproduced the same failure; restoring the line made the test pass again.

An independent staged review additionally caught two follow-ups before publication: an undefined owner snapshot could be re-resolved after an await, and the first integration test bypassed the controller bridge. Dedicated regressions now keep an unresolved snapshot unresolved and exercise the same handleStartWorkSessionRequest bridge used by the wiring effect.

Automated verification on current upstream main (f751a8c546):

Focused affected files:
  13 files, 233 tests passed

Additional UI tests changed by the upstream rebases:
  7 files, 106 tests passed

Desktop TypeScript:
  renderer + Electron + E2E typechecks passed

Changed-file ESLint:
  0 errors, 0 warnings

Changed-file Prettier:
  all files formatted

Production build:
  vite build + Electron bundles + native dependency staging passed
  assert-dist-built passed

git diff --check:
  passed

Full Desktop UI run on this Windows host before the subsequent conflict-free upstream rebases (starting from 5b82658b3c; --maxWorkers=2 to limit the host's worker-saturation timeouts):

598 files: 592 passed, 6 failed
5753 tests: 5741 passed, 12 failed

The failures are not introduced by this diff:

  • 8 worker-saturation/time-out follow-ons across gateway settings, unread tiles, and messaging all pass on immediate isolated rerun (12/12 tests).
  • The remaining 4 locale-sensitive assertions (1,234,567, 5,000, and currency spacing) reproduce identically on a clean current-upstream worktree under this host's de-DE locale (4 failed / 110 passed).

Checklist

Code

  • I've read the Contributing Guide and Desktop Engineering Guide
  • My commit follows Conventional Commits
  • I searched open PRs/issues and documented adjacent work above
  • The PR contains only changes for worktree hand-off ownership
  • Regression tests fail without the fix and pass with it
  • Typecheck, changed-file lint/format, focused tests, and production build pass
  • Full Desktop UI suite is zero-failure on this host (baseline-identical locale failures documented above)

Documentation & Housekeeping

  • User-facing docs: N/A; no command/configuration semantics changed
  • cli-config.yaml.example: N/A; no config keys changed
  • CONTRIBUTING.md / AGENTS.md: N/A; no workflow changed
  • Cross-platform impact considered; renderer-only TypeScript and generic path fixtures
  • Tool descriptions/schemas: N/A

@alt-glitch alt-glitch added type/bug Something isn't working comp/desktop Electron desktop app (apps/desktop/*) P3 Low — cosmetic, nice to have sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Aug 25, 2026
@Tomenatore
Tomenatore force-pushed the fix/desktop-worktree-profile-attribution branch 3 times, most recently from ddc734e to 63ede45 Compare August 25, 2026 23:44

@andrexibiza andrexibiza left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewing exact head 63ede45eddab2db15b31d76247af7bf5fea82f8e against its base main@a9351543789baabdd2897ee2ed73087d488f9b6c. Live main is now f751a8c5467c41500e505d90cb0eb8b70929080f, two updater-only commits ahead of this base; those intervening commits do not change the Desktop routing/worktree surfaces reviewed here.

The core direction is right: ownership is captured before the async worktree boundary, StartWorkSessionRequest carries it through the controller, tiles preserve exact connection routes, and the project/worktree entry points stop relying on whatever gateway happens to be active later. I found two merge-blocking ownership holes that still make the new carrier lossy/fail-open.

1. Ambiguous ownership is encoded as the same undefined used for genuinely ownerless fresh intent.

knownOwnerForSession() deliberately returns undefined when multiple connection-qualified claims exist. That is a valid resolution result ("this real source session is ambiguous"), but useComposerBranch immediately turns it into an ownerless worktree request:

requestStartWorkSession(path, text, owner ? { owner } : undefined)

The new regression even locks this behavior in as "keeps an unresolved owner unresolved" by expecting requestStartWorkSession(..., undefined).

That is not fail-closed at the actual mutation boundary. startFreshSessionDraft() only applies setNewChatOwner() when an owner is present, and desktopSessionCreateParams() subsequently resolves from the mutable new-chat/active-gateway state. So a same-stored-id collision across two connections can be correctly recognized as ambiguous and then silently reclassified as a normal ownerless fresh draft, allowing session.create to run under unrelated ambient/stale routing state.

This also contradicts the contract already documented in session-request-router.ts: ambient is for a genuinely unknown fresh draft/global surface; callers operating on a real session are expected to resolve its owner before reaching that case.

Required fix: preserve the distinction between no source owner exists and a source owner exists but cannot be uniquely resolved. A session-originated worktree handoff with ambiguous ownership must either resolve to one exact owner through authoritative evidence or refuse the handoff; it must not emit an ownerless StartWorkSessionRequest. Please cover the same-id/two-connection case through the controller bridge and assert that no ambient session.create occurs.

2. activeNewChatOwner() drops targetProfile, so it is not an exact route for Desktop aliases.

AgentProfileRoute correctly includes targetProfile, and the existing route machinery uses it when constructing the backend request. But the new capture helper reconstructs the active owner as only:

return connectionId ? { connectionId, profile } : profile

That loses a required identity dimension for configured alias routes. The existing Desktop route contract explicitly supports shapes like:

{ connectionId: C, profile: 'moxie', targetProfile: 'default' }

for per-profile Cloud/URL overrides; plugin-profile-routes.ts derives targetProfile = 'default' for exactly that case, and merged #92860 depends on the same connection-exact alias→backend-target distinction.

This PR now uses activeNewChatOwner() in project/open-folder/worktree entry points. After capture, desktopSessionCreateParams() sends capturedRoute?.targetProfile || profile. With the target discarded, the request is pinned to the correct connection but sends the Desktop alias (moxie) as the backend profile instead of the backend namespace (default). In other words, the patch fixes source attribution while still permitting profile-namespace attribution to drift.

Required fix: capture the full active route descriptor, including backend targetProfile, rather than reconstructing ownership from activeGatewayConnectionId() + $activeGatewayProfile. Add a regression for an active alias route where display profile != backend target and prove the worktree/project handoff calls session.create on the exact connection with the target backend profile.

Architecture/interlocks checked:

  • #89736 / #89731 remains complementary: it projects profile metadata only after a row was created under the correct owner; it cannot repair either creation-time routing hole above.
  • #81817 remains distinct: it handles the mutable profile-switch/Cmd+N race, while this PR is trying to make an initiating surface's owner sticky across a worktree transition.
  • #86822 is adjacent and overlaps the fresh-session/worktree creation seam. Whichever lands second must preserve both invariants: automatic worktree isolation and the full exact owner carrier; do not re-derive owner from foreground state during the merge adaptation.
  • #91684 is the same broader owner-routed-session defect family: a live session operation must reach the runtime/source that owns it, never a same-named/ambient gateway.
  • #92860 / #89131 establish why targetProfile is part of route identity rather than optional decoration; this PR should compose with that merged contract, not narrow it.
  • #92888 is the other side of the worktree ownership shape: background worker runtime state must not contaminate the foreground composer, while this PR carries foreground source authority into a new worktree session. They are complementary, not duplicates.

Exact-head hosted evidence is not green: CI, Docker Build/Test/Publish, and Nix flake check are all action_required; the CI run exposes zero jobs. The local focused/typecheck/build receipts in the PR body are useful, but they do not exercise the two adversarial ownership cases above.

Once ambiguity cannot collapse into ambient authority and targetProfile survives the capture, this becomes the right shape: one immutable initiating-owner carrier from interaction boundary through worktree creation to session.create, without reconstructing authority from mutable presentation state.

@Tomenatore
Tomenatore force-pushed the fix/desktop-worktree-profile-attribution branch from 63ede45 to cddbd0e Compare August 25, 2026 23:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/desktop Electron desktop app (apps/desktop/*) P3 Low — cosmetic, nice to have sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants