Skip to content

fix(state): inherit cwd/git_repo_root on parent_session_id children - #64731

Closed
wesleysimplicio wants to merge 2 commits into
NousResearch:mainfrom
wesleysimplicio:fix/session-cwd-inheritance
Closed

fix(state): inherit cwd/git_repo_root on parent_session_id children#64731
wesleysimplicio wants to merge 2 commits into
NousResearch:mainfrom
wesleysimplicio:fix/session-cwd-inheritance

Conversation

@wesleysimplicio

Copy link
Copy Markdown
Contributor

What does this PR do?

When a long conversation auto-compresses, a new child session row is created (parent_session_id
set, parent gets end_reason='compression') to continue the lineage. SessionDB._insert_session_row
never copied cwd/git_repo_root from the parent onto that child — worse, git_repo_root wasn't
even in the INSERT's column list at all, so it could only ever be set via a later,
separate update_session_cwd() call that the compression-fork path never makes. The same gap
applies to any other caller that creates a session with parent_session_id set (delegate/subagent
spawns, branch continuations) without explicitly passing cwd/git_repo_root.

The practical symptom: the Desktop project sidebar groups sessions into a Project by matching
sessions.cwd against the project's folder path, and only shows the tip of a lineage. Once a
compression fork lands with cwd = NULL, the project's entry silently disappears from the
sidebar — and since compression can fire repeatedly on a long-lived conversation, each generation
compounds the loss (a lineage that compresses 9 times ships 9 opportunities for something to
finally re-populate cwd, and none of them do, until a user manually patches the database).

The fix adds git_repo_root to _insert_session_row's INSERT/ON CONFLICT ... COALESCE column
set (matching the existing pattern already used for cwd, model, etc. — only fill a still-NULL
column, never clobber a value an earlier writer set), and adds a single-hop backfill from the
immediate parent row, inside the same write transaction, whenever parent_session_id is set. A
multi-generation chain still resolves correctly because each generation's own create_session call
already backfills from its own (by-then-already-resolved) immediate parent — no recursion needed.

Scope note on git_repo_root timing: gateway/Desktop sessions resolve git_repo_root
asynchronously (a background git probe, separate from the synchronous cwd set), so a child
created in the narrow window before that probe finishes will inherit the parent's cwd correctly
but not yet its git_repo_root (parent doesn't have it yet either at that instant). This is not a
new gap this PR introduces — the existing, independent SessionDB.backfill_repo_roots() sweep
(called wherever the project list resolves git roots) is keyed by cwd value, not by lineage,
so it already picks up any session sharing that cwd — including a child that inherited it from
this fix — once the probe resolves. No new code needed there.

How it works

flowchart TD
    A["Compression fires:\ncreate_session(parent_session_id=old_id)\n(no cwd/git_repo_root passed)"] --> B["_insert_session_row INSERT\n(git_repo_root now in column list)"]
    B --> C{"parent_session_id set?"}
    C -->|"before this PR"| D["child.cwd = NULL, child.git_repo_root = NULL\n→ project vanishes from Desktop sidebar"]
    C -->|"after this PR"| E["Same-transaction backfill UPDATE:\nchild.cwd/git_repo_root ← parent row\n(only fills NULLs, never overwrites)"]
    E --> F["child inherits parent's cwd/git_repo_root\n→ sidebar entry survives compression"]
Loading

Related Issue

Fixes #64709

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)

Changes Made

  • hermes_state.py: SessionDB._insert_session_row gains a git_repo_root parameter, adds it to the INSERT/ON CONFLICT column set (COALESCEd, same pattern as every other backfill-only column here), and adds a same-transaction UPDATE ... WHERE id = ? AND parent_session_id IS NOT NULL that backfills cwd/git_repo_root from the parent row via a correlated subquery, only when they're still NULL.
  • tests/test_hermes_state.py: three new tests in TestSessionLifecycle — direct parent→child inheritance, explicit child cwd/git_repo_root is never overwritten, and a 3-generation chain (root → gen1 → gen2) all resolving to the root's cwd.

Step-by-step

  1. Confirmed the root cause by reading agent/conversation_compression.py's compression-fork create_session(...) call: it passes parent_session_id=old_session_id but no cwd.
  2. Chose the "defense-in-depth" fix location (_insert_session_row itself) over patching the one call site, per the issue's own suggested alternative — this protects every current and future caller that creates a parent_session_id child without explicit cwd, not just the compression path.
  3. Added git_repo_root to the column list/COALESCE set, then a same-transaction backfill UPDATE keyed on the correlated parent lookup.
  4. Wrote a fail-before/pass-after test proving the inheritance actually happens; verified with a real (unmocked) SessionDB against a temp sqlite file.
  5. Ran an adversarial review pass (/simplicio-review, 1 reviewer). It independently built a standalone in-memory sqlite3 repro of the exact correlated-subquery UPDATE pattern to confirm it isn't a SQLite footgun, confirmed atomicity via _execute_write's single-transaction _do closure, and traced delegate/subagent call sites to confirm inheriting cwd there is desired (subagents run in-process, no isolated-directory concept to protect). It flagged one narrow edge case (the async git_repo_root probe timing, described above) — I traced it to the existing backfill_repo_roots() safety net and confirmed it's already covered, documented above rather than adding new code for it.

Acceptance Criteria

  • Given a parent session with cwd/git_repo_root set, when a child session is created with parent_session_id pointing at it and no cwd/git_repo_root of its own, then the child inherits both from the parent.
  • Given a child session created with its own explicit cwd/git_repo_root, when it also has parent_session_id set, then its own values are kept — never overwritten by the parent's.
  • Given a 3-generation lineage (root → gen1 → gen2) where only root has cwd set explicitly, when each generation is created in turn, then every generation ends up with root's cwd.
  • Adjacent behavior unchanged: all pre-existing create_session/cwd-related tests still pass.
  • Test suite passes locally with the new tests included.

How to Test

  1. On main: create a session, set its cwd via update_session_cwd, then create a second session with parent_session_id pointing at the first and no cwd — the second session's cwd is NULL.
  2. Check out this branch.
  3. Same steps — the second session inherits the first's cwd (and git_repo_root, if set).

Tests Performed

Check Command Result
New inheritance tests python -m pytest tests/test_hermes_state.py -k "inherit or create_session or cwd" -q 16 passed
Full hermes_state suite python -m pytest tests/test_hermes_state.py -q 369 passed in 43.70s
Compression-fork tests python -m pytest tests/agent/test_compression_concurrent_fork.py -q 23 passed
Related lineage/session tests python -m pytest tests/test_lazy_session_regressions.py tests/hermes_state/test_session_archiving.py tests/hermes_state/test_resolve_resume_session_id.py tests/hermes_state/test_session_md_export.py -q 36 passed
Windows footguns python scripts/check-windows-footguns.py hermes_state.py tests/test_hermes_state.py No Windows footguns found (2 file(s) scanned).
Lint python -m ruff check hermes_state.py tests/test_hermes_state.py All checks passed!
Fail-before/pass-after New tests run against pre-fix code ❌ 2 of 3 fail (assert None == '/work/repo'); all pass after the fix

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix
  • I've run the tests and all pass
  • I've added tests for my changes
  • I've tested on my platform: Windows 11

Documentation & Housekeeping

  • Relevant documentation updated — N/A (internal DB-layer behavior, no user-facing docs describe it)
  • cli-config.yaml.example updated if config keys changed — N/A
  • CONTRIBUTING.md/AGENTS.md updated if architecture/workflow changed — N/A
  • Cross-platform impact considered (Windows, macOS) — pure SQLite logic, platform-agnostic; issue was reported on Windows but the mechanism is identical everywhere
  • Tool descriptions/schemas updated if tool behavior changed — N/A

Out of scope (intentionally, flagged for a maintainer to decide)

The issue also suggests a one-time migration to backfill cwd/git_repo_root on already-affected
lineages (existing NULL-cwd tips from before this fix, requiring a recursive/fixpoint sweep across
potentially many generations). That's a data-repair concern distinct from this code fix (which
prevents the problem going forward) — left out to keep this PR to the smallest change that resolves
the reported bug; happy to follow up with a migration if maintainers want one.

Screenshots / Logs

$ python -m pytest tests/test_hermes_state.py -q
........................................................................ [ 39%]
........................................................................ [ 58%]
........................................................................ [ 78%]
........................................................................ [ 97%]
.........                                                                [100%]
369 passed in 43.70s

_insert_session_row never copied cwd/git_repo_root from a parent row when
parent_session_id was set, and git_repo_root wasn't even in the INSERT's
column list. The compression-fork path (and delegate/subagent spawns,
branch continuations) creates a child session without passing cwd/
git_repo_root at all, so the child's tip is born NULL — and since the
Desktop project sidebar groups sessions by cwd, the whole project silently
drops out of the sidebar every time a long conversation compresses. A
lineage that compresses repeatedly compounds this across generations.

Add git_repo_root to _insert_session_row's INSERT/COALESCE-on-conflict
column set, and backfill both cwd and git_repo_root from the immediate
parent row (single non-recursive hop, matching the existing COALESCE
"never overwrite an explicit value" contract) inside the same write
transaction whenever parent_session_id is set. A multi-generation chain
resolves correctly because each generation's own create_session call
already backfills from its (already-resolved) immediate parent.

Fixes NousResearch#64709
@alt-glitch alt-glitch added type/bug Something isn't working P3 Low — cosmetic, nice to have comp/cli CLI entry point, hermes_cli/, setup wizard comp/desktop Electron desktop app (apps/desktop/*) sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Jul 15, 2026
Resolves conflict in hermes_state.py's _insert_session_row: main added
profile_name, this branch adds git_repo_root - kept both parameters,
both in the INSERT column list/VALUES/ON CONFLICT upsert, and both in
the bound-values tuple, in the same order.

Also marks a pre-existing test fixture in
tests/gateway/test_multiplex_adapter_registry.py (a fake Discord token
used only to test credential fingerprinting) with the repo's allowlist
convention so the secret scanner does not flag it on merge.
@teknium1

Copy link
Copy Markdown
Contributor

Thanks for the focused state-layer fix. The premise remains valid on current main: the rotation path in agent/conversation_compression.py:896-902 creates a child with parent_session_id but no workspace metadata, while SessionDB._insert_session_row only persists the supplied cwd and does not include git_repo_root (hermes_state.py:1749-1818). workspace_key() consequently returns None when both fields are absent (hermes_state.py:35-49).

The proposed centralized, NULL-only parent backfill fits the existing non-clobbering upsert convention and also covers the other direct parent-linked child creators, including gateway branch creation (gateway/slash_commands.py:3844-3850) and API session forks (gateway/platforms/api_server.py:2020-2026). The direct inheritance, explicit-child-value, and multi-generation tests cover the intended contract.

Automated hermes-sweeper review.

@teknium1 teknium1 added the sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform label Jul 16, 2026
teknium1 added a commit that referenced this pull request Jul 22, 2026
…n children

Follow-ups on top of #64731's cwd/git_repo_root inheritance:

- git_branch joins the parent-row backfill (same NULL-only COALESCE hop):
  the Desktop sidebar branch chip otherwise vanishes at every compaction
  boundary even though the workspace didn't change.
- Belt-and-suspenders for #59527: compression forks (parent already ended
  with end_reason='compression') also inherit the gateway origin columns
  (user_id/session_key/chat_id/chat_type/thread_id/display_name/
  origin_json) at DB-level child creation. The gateway re-records the peer
  after rotation (d5b4879), but a hard crash in the window between child
  creation and that write left the child unrecoverable by
  find_latest_gateway_session_for_peer. Scoped to compression forks only —
  delegate/subagent children (parent still live) must NOT inherit routing
  keys, or peer recovery could repoint gateway traffic into a subagent's
  session.
- Behavioral test driving the real _compress_context rotation path,
  asserting the child row carries cwd/git_repo_root/git_branch and the
  origin columns.
@teknium1

Copy link
Copy Markdown
Contributor

Merged via #69343 (commit 75099ca). Your commit was cherry-picked with authorship preserved; a follow-up added git_branch and compression-scoped gateway-origin inheritance. Thanks!

@teknium1 teknium1 closed this Jul 22, 2026
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
…n children

Follow-ups on top of NousResearch#64731's cwd/git_repo_root inheritance:

- git_branch joins the parent-row backfill (same NULL-only COALESCE hop):
  the Desktop sidebar branch chip otherwise vanishes at every compaction
  boundary even though the workspace didn't change.
- Belt-and-suspenders for NousResearch#59527: compression forks (parent already ended
  with end_reason='compression') also inherit the gateway origin columns
  (user_id/session_key/chat_id/chat_type/thread_id/display_name/
  origin_json) at DB-level child creation. The gateway re-records the peer
  after rotation (fef540d), but a hard crash in the window between child
  creation and that write left the child unrecoverable by
  find_latest_gateway_session_for_peer. Scoped to compression forks only —
  delegate/subagent children (parent still live) must NOT inherit routing
  keys, or peer recovery could repoint gateway traffic into a subagent's
  session.
- Behavioral test driving the real _compress_context rotation path,
  asserting the child row carries cwd/git_repo_root/git_branch and the
  origin columns.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cli CLI entry point, hermes_cli/, setup wizard comp/desktop Electron desktop app (apps/desktop/*) P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform 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.

bug(state): compression-split child sessions lose cwd/git_repo_root, causing project sidebar entry to disappear every compression

3 participants