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
- 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.
Next step: read #23530, then check whether your report is one of the 2 still open.
Read #23530Pick up a Lab taskThese 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
Open PRs1
- PR#42392fix(goal): defer background review during continuation
Fixes on record
-
#23530 fix(goals): forward standing /goal state on auto-compression session rotation
files:
run_agent.py,tests/hermes_cli/test_goals.pyShow 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
files:
agent/conversation_compression.py,hermes_cli/goals.py,tests/agent/test_compression_rotation_state.py,tests/hermes_cli/test_goals.pyShow 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]
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.00means 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