Goal state lost when session ID rotates on compression

NousResearch/hermes-agent · cluster 18467 · agent

Data as of

3 open items. Fix merged in #23530. Check it covers your case.

The short answer

2open issues
1open PR
24items grouped
Known?
Yes. 24 related items grouped here, 6 issues already closed.
Fix event
A fix was merged to main in PR #23530. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
2 issues and 1 PR.

These issues and fixes center on /goal state being dropped when context compression creates a new session_id, covering migration logic, session-independent storage, background review interference, and in-place compaction as an alternative approach.

Open issues2

  • Issue#19077Goals lost when session ends — design contradicts 'persistent across sessions' intent
  • Issue#42391TUI /goal continuation can be preempted by background review after session split

Open PRs1

  • PR#42392fix(goal): defer background review during continuation

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #23530 fix(goals): forward standing /goal state on auto-compression session rotation

    merged to main

    files: run_agent.py, tests/hermes_cli/test_goals.py

    Show the diff
    • run_agent.py
      @@ -10080,6 +10080,25 @@ def _compress_context(self, messages: list, system_message: str, *, approx_token
                           parent_session_id=old_session_id,
                       )
                       self._session_db_created = True
      +                # Forward any standing /goal state from the parent session to
      +                # the continuation session so the goal loop survives
      +                # auto-compression. Without this rebind, _get_goal_manager()
      +                # constructs a fresh manager keyed on the new session_id,
      +                # load_goal() returns None, mgr.is_active() is False, and
      +                # the loop silently dies mid-task. The goal is stored in
      +                # state_meta under "goal:<sid>" by hermes_cli.goals.
      +                try:
      +                    _goal_meta_key_old = f"goal:{old_session_id}"
      +                    _goal_meta_key_new = f"goal:{self.session_id}"
      +                    _goal_blob = self._session_db.get_meta(_goal_meta_key_old)
      +                    if _goal_blob:
      +                        self._session_db.set_meta(_goal_meta_key_new, _goal_blob)
      +                        logger.info(
      +                            "goal: forwarded standing goal from %s → %s on compression",
      +                            old_session_id, self.session_id,
      +                        )
      +                except Exception as exc:
      +                    logger.debug("goal forward on compression failed: %s", exc)
                       # Auto-number the title for the continuation session
                       if old_title:
                           try:
    • tests/hermes_cli/test_goals.py
      @@ -1071,3 +1071,59 @@ def create(**kwargs):
               # Other items still pending.
               assert mgr.state.checklist[1].status == ITEM_PENDING
               assert mgr.state.checklist[2].status == ITEM_PENDING
      +
      +
      +# ──────────────────────────────────────────────────────────────────────
      +# Compression session-rotation: goal must follow the new session_id
      +# ──────────────────────────────────────────────────────────────────────
      +
      +
      +class TestGoalSurvivesCompressionRotation:
      +    def test_load_goal_after_session_id_rotates(self, hermes_home):
      +        """When auto-compression rotates the session_id, the goal must be
      +        readable from the new session_id (forwarded by run_agent's
      +        _compress_context block).
      +
      +        We don't run the full _compress_context method here — it has
      +        ~60 dependencies. Instead we mirror exactly what that block does
      +        with state_meta and assert the goal manager picks it up.
      +        """
      +        from hermes_cli.goals import GoalManager
      +        from hermes_state import SessionDB
      +
      +        # Create a goal under a parent session_id.
      +        parent_sid = "parent-rotate-001"
      +        mgr = GoalManager(session_id=parent_sid)
      +        mgr.set("survive compression")
      +        assert mgr.is_active()
      +
      +        # Simulate the run_agent._compress_context forwarding block:
      +        # read goal:<old>, write goal:<new> on the same SessionDB instance.
      +        db = SessionDB()
      +        new_sid = "child-rotate-001"
      +        blob = db.get_meta(f"goal:{parent_sid}")
      +        assert blob, "goal must be in state_meta"
      +        db.set_meta(f"goal:{new_sid}", blob)
      +
      +        # New GoalManager for the rotated session_id should load the same goal.
      +        mgr2 = GoalManager(session_id=new_sid)
      +        assert mgr2.is_active()
      +        assert mgr2.state.goal == "survive compression"
      +        # Counters/checklist preserved verbatim.
      +        assert mgr2.state.turns_used == mgr.state.turns_used
      +        assert mgr2.state.checklist == mgr.state.checklist
      +
      +    def test_no_forward_when_no_goal(self, hermes_home):
      +        """Forwarding is a no-op when the parent session has no goal."""
      +        from hermes_state import SessionDB
      +        from hermes_cli.goals import load_goal
      +
      +        db = SessionDB()
      +        # Parent has no goal at all.
      +        assert db.get_meta("goal:parent-no-goal") is None
      +        blob = db.get_meta("goal:parent-no-goal")
      +        if blob:  # parity with production guard
      +            db.set_meta("goal:child-no-goal", blob)
      +
      +        # Child should still have no goal.
      +        assert load_goal("child-no-goal") is None
  • #49772 fix(compression): preserve goal, platform, and session indexing across rotation

    merged to main

    files: agent/conversation_compression.py, hermes_cli/goals.py, tests/agent/test_compression_rotation_state.py, tests/hermes_cli/test_goals.py

    Show the diff
    • agent/conversation_compression.py
      @@ -592,14 +592,62 @@ def _release_lock() -> None:
                       except Exception:
                           pass
                       agent._session_db_created = False
      -                agent._session_db.create_session(
      -                    session_id=agent.session_id,
      -                    source=agent.platform or os.environ.get("HERMES_SESSION_SOURCE", "cli"),
      -                    model=agent.model,
      -                    model_config=agent._session_init_model_config,
      -                    parent_session_id=old_session_id,
      -                )
      +                try:
      +                    agent._session_db.create_session(
      +                        session_id=agent.session_id,
      +                        source=agent.platform or os.environ.get("HERMES_SESSION_SOURCE", "cli"),
      +                        model=agent.model,
      +                        model_config=agent._session_init_model_config,
      +                        parent_session_id=old_session_id,
      +                    )
      +                except Exception as _cs_err:
      +                    # The child row could not be created (e.g. FK constraint,
      +                    # contended write). Previously the outer handler simply
      +                    # warned and let the agent continue on the NEW id — which
      +                    # has no row in state.db, producing an orphan: the parent
      +                    # is ended, the child is never indexed, and every
      +                    # subsequent message is attributed to a session that
      +                    # doesn't exist (#33906/#33907). Roll the live id back to
      +                    # the parent so the conversation stays attached to a real,
      +                    # indexed session instead of a phantom.
      +                    logger.warning(
      +                        "Compression child session create failed (%s) — "
      +                        "rolling back to parent session %s to avoid an orphan.",
      +                        _cs_err, old_session_id,
      +                    )
      +                    agent.session_id = old_session_id
      +                    try:
      +                        from gateway.session_context import set_current_session_id
      +                        set_current_session_id(agent.session_id)
      +                    except Exception:
      +                        os.environ["HERMES_SESSION_ID"] = agent.session_id
      +                    try:
      +                        from hermes_logging import set_session_context
      +                        set_session_context(agent.session_id)
      +                    except Exception:
      +                        pass
      +                    # Re-open the parent: it was ended above, but we're
      +                    # continuing on it, so it must not stay closed.
      +                    try:
      +                        agent._session_db.reopen_session(old_session_id)
      +                    except Exception:
      +                        pass
      +                    old_session_id = None  # no rotation happened
      +                    # The parent row already exists in state.db, so mark the
      +                    # session as created — _ensure_db_session would otherwise
      +                    # retry a (harmless INSERT OR IGNORE) create next turn.
      +                    agent._session_db_created = True
      +                    raise
                       agent._session_db_created = True
      +                # Carry a persistent /goal onto the continuation session.
      +                # Compression mints a fresh child id; load_goal does a flat
      +                # per-session lookup with no parent walk, so without this an
      +                # active goal silently dies at the boundary (#33618).
      +                try:
      +                    from hermes_cli.goals import migrate_goal_to_session
      +                    migrate_goal_to_session(old_session_id, agent.session_id, reason="compression")
      +                except Exception as _goal_err:
      +                    logger.debug("Could not migrate goal on compression: %s", _goal_err)
                       # Auto-number the title for the continuation session
                       if old_title:
                           try:
      @@ -615,7 +663,18 @@ def _release_lock() -> None:
                   agent._session_db.update_system_prompt(agent.session_id, new_system_prompt)
                   agent._last_flushed_db_idx = 0
               except Exception as e:
      -            logger.warning("Session DB compression split failed — new session will NOT be indexed: %s", e)
      +            # If the rotation rolled back to the parent (orphan-avoidance
      +            # above), agent.session_id is the still-indexed parent and
      +            # old_session_id was cleared — so this is recovery, not an
      +            # un-indexed orphan. Otherwise an earlier step failed before the
      +            # child was created and the warning's original meaning holds.
      +            if locals().get("old_session_id") is None and not in_place:
      +                logger.warning(
      +                    "Compression rotation aborted and rolled back to the "
      +                    "parent session (%s): %s", agent.session_id or "?", e,
      +                )
      +            else:
      +                logger.warning("Session DB compression split failed — new session will NOT be indexed: %s", e)
       
           # Compaction-boundary bookkeeping, computed once. `old_session_id` is only
           # bound in the rotation branch; in-place leaves it unset. `_boundary_parent`
      @@ -637,6 +696,7 @@ def _release_lock() -> None:
                       agent.session_id or "",
                       boundary_reason="compression",
                       old_session_id=_boundary_parent,
      +                platform=getattr(agent, "platform", None) or "cli",
                       conversation_id=getattr(agent, "_gateway_session_key", None),
                   )
           except Exception as _ce_err:
    • hermes_cli/goals.py
      @@ -279,6 +279,44 @@ def clear_goal(session_id: str) -> None:
           save_goal(session_id, state)
       
       
      +def migrate_goal_to_session(old_session_id: str, new_session_id: str, *, reason: str = "") -> bool:
      +    """Carry a persistent /goal from a parent session to its continuation.
      +
      +    Context compression rotates ``session_id`` to a fresh child session,
      +    but ``load_goal`` does a flat ``goal:<session_id>`` lookup with no
      +    parent-lineage walk — so an active goal silently dies at the
      +    compaction boundary (#33618). Copy the goal onto the new session and
      +    archive the old row as ``cleared`` so exactly one active goal row
      +    exists per logical conversation (avoids the "two active goals"
      +    hazard of a pure copy).
      +
      +    Returns True when a goal was migrated, False when there was nothing
      +    to migrate or the DB was unavailable. Best-effort and never raises —
      +    a failure here must not block compression.
      +    """
      +    if not old_session_id or not new_session_id or old_session_id == new_session_id:
      +        return False
      +    try:
      +        state = load_goal(old_session_id)
      +        if state is None or getattr(state, "status", None) == "cleared":
      +            return False
      +        # Don't clobber a goal already set on the child (e.g. a resumed
      +        # lineage that re-established its own goal).
      +        if load_goal(new_session_id) is not None:
      +            return False
      +        save_goal(new_session_id, state)
      +        # Archive the parent's row so it isn't double-counted as active.
      +        clear_goal(old_session_id)
      +        logger.debug(
      +            "GoalManager: migrated goal %s -> %s (%s)",
      +            old_session_id, new_session_id, reason or "rotation",
      +        )
      +        return True
      +    except Exception as exc:  # pragma: no cover - defensive
      +        logger.debug("GoalManager: goal migration failed: %s", exc)
      +        return False
      +
      +
       # ──────────────────────────────────────────────────────────────────────
       # Judge
       # ──────────────────────────────────────────────────────────────────────
      @@ -907,6 +945,7 @@ def _log(msg: str) -> None:
           "load_goal",
           "save_goal",
           "clear_goal",
      +    "migrate_goal_to_session",
           "judge_goal",
           "run_kanban_goal_loop",
       ]
    • tests/agent/test_compression_rotation_state.py
      @@ -0,0 +1,129 @@
      +"""Compression rotation hardening — state-loss fixes at the compaction boundary.
      +
      +When auto-compression rotates ``agent.session_id`` to a continuation child,
      +three pieces of state used to be lost or corrupted:
      +
      +  * #33618 — a persistent ``/goal`` did not follow the rotation (``load_goal``
      +    is a flat per-session lookup with no lineage walk), so it silently died.
      +  * #33906/#33907 — if the child ``create_session`` raised, the outer handler
      +    only warned and let the agent continue on the NEW (un-indexed) id,
      +    producing an orphan session missing from state.db.
      +  * #27633 — the compaction-boundary ``on_session_start`` notification omitted
      +    the ``platform`` kwarg, so context-engine plugins saw ``source=unknown``
      +    for every message after the boundary.
      +
      +These tests drive the real ``compress_context`` path against a real SessionDB.
      +"""
      +
      +from __future__ import annotations
      +
      +import os
      +from pathlib import Path
      +from unittest.mock import MagicMock, patch
      +
      +from hermes_state import SessionDB
      +
      +
      +def _build_agent_with_db(db: SessionDB, session_id: str, platform: str = "telegram"):
      +    with patch.dict(os.environ, {"OPENROUTER_API_KEY": "test-key"}):
      +        from run_agent import AIAgent
      +
      +        agent = AIAgent(
      +            api_key="[redacted:secret-assignment]",
      +            base_url="https://openrouter.ai/api/v1",
      +            model="test/model",
      +            platform=platform,
      +            quiet_mode=True,
      +            session_db=db,
      +            session_id=session_id,
      +            skip_context_files=True,
      +            skip_memory=True,
      +        )
      +
      +    compressor = MagicMock()
      +    compressor.compress.return_value = [
      +        {"role": "user", "content": "[CONTEXT COMPACTION] summary"},
      +        {"role": "user", "content": "tail"},
      +    ]
      +    compressor.compression_count = 1
      +    compressor.last_prompt_tokens = 0
      +    compressor.last_completion_tokens = 0
      +    compressor._last_summary_error = None
      +    compressor._last_compress_aborted = False
      +    compressor._last_summary_auth_failure = False
      +    compressor._last_aux_model_failure_model = None
      +    compressor._last_aux_model_failure_error = None
      +    agent.context_compressor = compressor
      +    return agent
      +
      +
      +def _msgs(n=20):
      +    return [{"role": "user", "content": f"m{i}"} for i in range(n)]
      +
      +
      +class TestGoalMigratesOnRotation:
      +    def test_goal_follows_compression_rotation(self, tmp_path: Path):
      +        db = SessionDB(db_path=tmp_path / "state.db")
      +        parent = "PARENT_GOAL_ROT"
      +        db.create_session(parent, source="cli")
      +        agent = _build_agent_with_db(db, parent)
      +
      +        # Set a persistent goal on the parent via the real persistence path.
      +        with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}):
      +            (tmp_path / ".hermes").mkdir(exist_ok=True)
      +            import hermes_cli.goals as goals
      +            goals._DB_CACHE.clear()
      +            # Point the goal DB at the same state.db the agent uses.
      +            with patch.object(goals, "_get_session_db", return_value=db):
      +                goals.save_goal(parent, goals.GoalState(goal="finish the migration"))
      +
      +                agent._compress_context(_msgs(), "sys", approx_tokens=[redacted:secret-assignment]
      +                child = agent.session_id
      +                assert child != parent  # rotation happened
      +
      +                migrated = goals.load_goal(child)
      +                assert migrated is not None
      +                assert migrated.goal == "finish the migration"
      +            goals._DB_CACHE.clear()
      +
      +
      +class TestOrphanRollbackOnCreateFailure:
      +    def test_rolls_back_to_parent_when_child_create_fails(self, tmp_path: Path):
      +        db = SessionDB(db_path=tmp_path / "state.db")
      +        parent = "PARENT_ORPHAN_ROT"
      +        db.create_session(parent, source="cli")
      +        agent = _build_agent_with_db(db, parent)
      +
      +        # Make the CHILD create_session raise, but let the initial parent
      +        # end_session/reopen work. We patch create_session to blow up.
      +        real_create = db.create_session
      +
      +        def _boom(*a, **k):
      +            raise RuntimeError("FOREIGN KEY constraint failed")
      +
      +        with patch.object(db, "create_session", side_effect=_boom):
      +            agent._compress_context(_msgs(), "sys", approx_tokens=[redacted:secret-assignment]
      +
      +        # The live id must roll back to the still-indexed parent — NOT a
      +        # phantom child id that has no row in state.db.
      +        assert agent.session_id == parent
      +        assert db.get_session(parent) is not None
      +        _ = real_create  # silence unused
      +
      +
      +class TestPlatformForwardedAtBoundary:
      +    def test_on_session_start_receives_platform(self, tmp_path: Path):
      +        db = SessionDB(db_path=tmp_path / "state.db")
      +        parent = "PARENT_PLATFORM_ROT"
      +        db.create_session(parent, source="telegram")
      +        agent = _build_agent_with_db(db, parent, platform="telegram")
      +
      +        agent._compress_context(_msgs(), "sys", approx_tokens=[redacted:secret-assignment]
      +
      +        # The boundary notify must forward the platform so context-engine
      +        # plugins don't fall back to source=unknown (#27633).
      +        calls = [c for c in agent.context_compressor.on_session_start.call_args_list]
      +        assert calls, "on_session_start was not called at the boundary"
      +        kwargs = calls[-1].kwargs
      +        assert kwargs.get("platform") == "telegram"
      +        assert kwargs.get("boundary_reason") == "compression"
    • tests/hermes_cli/test_goals.py
      @@ -547,6 +547,47 @@ def test_subgoals_round_trip(self):
               assert rt.subgoals == ["a", "b", "c"]
       
       
      +class TestMigrateGoalToSession:
      +    """migrate_goal_to_session carries a /goal from a parent session to its
      +    compression continuation child (#33618). load_goal does a flat
      +    per-session lookup with no lineage walk, so without migration an active
      +    goal silently dies when compression rotates session_id."""
      +
      +    def test_migrates_active_goal_to_child(self, hermes_home):
      +        from hermes_cli.goals import save_goal, load_goal, migrate_goal_to_session, GoalState
      +        save_goal("parent-sid", GoalState(goal="ship the feature"))
      +        assert migrate_goal_to_session("parent-sid", "child-sid", reason="compression") is True
      +        child = load_goal("child-sid")
      +        assert child is not None and child.goal == "ship the feature"
      +        # Parent row archived (cleared) so only the child is active.
      +        parent = load_goal("parent-sid")
      +        assert parent is not None and parent.status == "cleared"
      +
      +    def test_no_goal_to_migrate_returns_false(self, hermes_home):
      +        from hermes_cli.goals import migrate_goal_to_session, load_goal
      +        assert migrate_goal_to_session("empty-parent", "child2") is False
      +        assert load_goal("child2") is None
      +
      +    def test_does_not_clobber_existing_child_goal(self, hermes_home):
      +        from hermes_cli.goals import save_goal, load_goal, migrate_goal_to_session, GoalState
      +        save_goal("p3", GoalState(goal="parent goal"))
      +        save_goal("c3", GoalState(goal="child already has one"))
      +        assert migrate_goal_to_session("p3", "c3") is False
      +        assert load_goal("c3").goal == "child already has one"
      +
      +    def test_same_id_is_noop(self, hermes_home):
      +        from hermes_cli.goals import save_goal, migrate_goal_to_session, GoalState
      +        save_goal("same", GoalState(goal="g"))
      +        assert migrate_goal_to_session("same", "same") is False
      +
      +    def test_cleared_goal_not_migrated(self, hermes_home):
      +        from hermes_cli.goals import save_goal, clear_goal, migrate_goal_to_session, load_goal, GoalState
      +        save_goal("p4", GoalState(goal="done already"))
      +        clear_goal("p4")
      +        assert migrate_goal_to_session("p4", "c4") is False
      +        assert load_goal("c4") is None
      +
      +
       class TestGoalManagerSubgoals:
           def test_add_subgoal(self, hermes_home):
               from hermes_cli.goals import GoalManager
  • #52658 feat(compression): flip in_place default to True + guard fix (#38763) [salvage #51959]

    merged to main

    files: gateway/run.py, gateway/slash_commands.py, hermes_cli/config.py, scripts/release.py, tests/agent/test_compression_concurrent_fork.py, tests/agent/test_compression_logging_session_context.py, tests/agent/test_compression_rotation_state.py, tests/gateway/test_compression_concurrent_sessions.py, tests/gateway/test_session_hygiene.py, tests/run_agent/test_compression_boundary_hook.py, tests/run_agent/test_in_place_compaction.py

12 PRs closed without merging

From the close comments: superseded by another PR 7 · duplicate 2 · done elsewhere 1 · merged via another change 1 · other 1

  • #18427 fix: preserve goals across compression session splits · superseded by another PR → #52658, #38763
  • #18749 fix(cli, gateway): migrate /goal across all session_id rebind sites · done elsewhere → 7ace96b, #23530
  • #19125 fix(goals): store goals under session-independent key to survive session boundaries · duplicate → #18749, #18427
  • #29955 fix(goals): migrate active goal across compression split · superseded by another PR → #52658, #38763
  • #33620 fix: preserve goals across compression sessions · merged via another change → #49772
Show 7 more
  • #33890 feat: migrate active goals across session_id rotation (#33618) · superseded by another PR → #52658, #38763
  • #34035 fix(agent): migrate goal state across compression session rotation · superseded by another PR → #52658, #38763
  • #38231 fix: preserve goals across compression sessions · superseded by another PR → #52658, #38763
  • #42519 fix(agent): suppress bg review during active goal, inherit parent goal (#42391) · other
  • #45065 fix(gateway): migrate active /goal to new session on context compression · duplicate → #18427, #18749, #33890, #34035, #45065
  • #48980 fix(goals): forward standing /goal state when auto-compression rotates session_id · superseded by another PR → #52658, #38763
  • #49743 fix(goals): keep gateway goals alive across streaming and compression · superseded by another PR → #52658, #38763
Closed issues · 6
  • Closed#18467[Bug]: /goal silently lost after manual /compress (session_id rebind doesn't migrate goal state)
  • Closed#25406fix: wire GoalForwarder on session compression (hermes parity)
  • Closed#33618Persistent /goal is lost after context compression rotates session_id
  • Closed#38763fix(compression): eliminate session duplication -- adopt in-place compaction like Claude Code and Codex
  • Closed#45059bug: /goal lost after context compression rotates session_id
  • Closed#48956fix(goals): forward /goal state_meta when auto-compression rotates session_id
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.08: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#23530, #49772, #52658
Closed issues
6
Attempts
12 PRs closed without merging
Fix link checked
25 Jun 2026

For your agent

The same facts, typed.