From c2872cf53b6529ae0cb7f44ac4d78718b0695e48 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 31 Jul 2026 14:03:32 -0500 Subject: [PATCH] fix(kanban): key the worker-session retag per board, not per database The retag gate was global, so once one board reclaimed its legacy rows a second board on the same state.db never got swept. Key the state_meta gate on the workspaces root and skip reopening state.db on every spawn via an in-process set. Align the dispatcher-spawn test with the worker's own `kanban` source tag and cover the per-board gate. --- hermes_cli/kanban_db.py | 12 ++++++++++-- hermes_state.py | 13 +++++++------ tests/hermes_cli/test_kanban_db.py | 8 ++++++++ .../test_kanban_worker_session_source.py | 17 ++++++++++++++++- 4 files changed, 41 insertions(+), 9 deletions(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index f9ec2e20014..5b688a31aa3 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -8841,12 +8841,19 @@ def _resolve_worker_cli_toolsets(hermes_home: Optional[str]) -> Optional[list[st return None +_retagged_workspace_roots: set[str] = set() + + def _retag_legacy_worker_sessions(workspaces_root_path: str) -> None: """Reclaim pre-tag worker rows in state.db so they leave the session lists. - Best-effort and gated to run once per database — a dispatcher tick must - never fail because a session DB was busy or missing. + Best-effort and gated — the durable ``state_meta`` gate lives in + ``retag_kanban_worker_sessions``; the in-process set keeps a busy + dispatcher from reopening state.db on every spawn just to read it. A + dispatcher tick must never fail because a session DB was busy or missing. """ + if workspaces_root_path in _retagged_workspace_roots: + return try: from hermes_state import SessionDB @@ -8855,6 +8862,7 @@ def _retag_legacy_worker_sessions(workspaces_root_path: str) -> None: db.retag_kanban_worker_sessions(workspaces_root_path) finally: db.close() + _retagged_workspace_roots.add(workspaces_root_path) except Exception as exc: _log.debug("kanban worker: legacy session retag skipped (%s)", exc) diff --git a/hermes_state.py b/hermes_state.py index a35e5efd6e3..16473d9e3b3 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -7758,16 +7758,17 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin) too. Identified by cwd under the board's workspaces root — a path only the dispatcher ever runs a session in. - Runs once per database (``state_meta`` gate) and returns the number of - rows retagged. + Gated per workspaces root (``state_meta``) so each board reclaims its + own rows exactly once. Returns the number of rows retagged. """ - if self.get_meta("kanban_worker_source_retagged") == "1": - return 0 - prefix = str(workspaces_root).rstrip("/\\") if not prefix: return 0 + gate = f"kanban_worker_source_retagged:{prefix}" + if self.get_meta(gate) == "1": + return 0 + def _do(conn): cursor = conn.execute( "UPDATE sessions SET source = 'kanban' " @@ -7777,7 +7778,7 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin) # Read rowcount before set_meta reuses this cursor for its INSERT, # which would otherwise overwrite it with the meta write's count. retagged = cursor.rowcount or 0 - self.set_meta("kanban_worker_source_retagged", "1", cursor=cursor) + self.set_meta(gate, "1", cursor=cursor) return retagged return self._execute_write(_do) diff --git a/tests/hermes_cli/test_kanban_db.py b/tests/hermes_cli/test_kanban_db.py index b25d12c774b..7de9b4c0b7c 100644 --- a/tests/hermes_cli/test_kanban_db.py +++ b/tests/hermes_cli/test_kanban_db.py @@ -792,6 +792,9 @@ class TestSharedBoardPaths: ): # The dispatcher must pin board paths while stripping any unrelated # HERMES_SESSION_* identity inherited from the long-lived gateway. + # The one exception is HERMES_SESSION_SOURCE, which the dispatcher + # re-sets to its own `kanban` tag AFTER the strip — a value it owns, + # never one inherited from whatever the gateway last routed. default_home = tmp_path / ".hermes" default_home.mkdir() self._set_home(monkeypatch, tmp_path, default_home) @@ -842,6 +845,11 @@ class TestSharedBoardPaths: assert env["HERMES_KANBAN_TASK"] == "t_dispatch_env" assert env["HERMES_KANBAN_BRANCH"] == "wt/t_dispatch_env" for key in sc._VAR_MAP: + if key == "HERMES_SESSION_SOURCE": + # Re-set by the dispatcher, so what matters is that it carries + # the worker's own tag rather than the inherited routing value. + assert env[key] == "kanban" + continue assert key not in env diff --git a/tests/hermes_cli/test_kanban_worker_session_source.py b/tests/hermes_cli/test_kanban_worker_session_source.py index e6ea37ead51..b35dcc1781a 100644 --- a/tests/hermes_cli/test_kanban_worker_session_source.py +++ b/tests/hermes_cli/test_kanban_worker_session_source.py @@ -91,7 +91,7 @@ def test_retag_reclaims_legacy_worker_rows(db, tmp_path): assert sources == {"legacy": "kanban", "legacy2": "kanban", "mine": "cli"} -def test_retag_runs_once_per_database(db, tmp_path): +def test_retag_runs_once_per_workspaces_root(db, tmp_path): """The state_meta gate keeps the retag off every subsequent spawn.""" workspaces = tmp_path / "kanban" / "workspaces" db.create_session(session_id="legacy", source="cli", cwd=str(workspaces / "t_a")) @@ -104,3 +104,18 @@ def test_retag_runs_once_per_database(db, tmp_path): assert db.retag_kanban_worker_sessions(str(workspaces)) == 0 row = db._conn.execute("SELECT source FROM sessions WHERE id = 'later'").fetchone() assert row[0] == "cli" + + +def test_retag_gate_is_per_board(db, tmp_path): + """A second board's workspaces root still gets its own sweep. + + The gate is keyed on the root, so reclaiming board A must not convince the + dispatcher that board B's legacy rows were already handled. + """ + board_a = tmp_path / "kanban" / "boards" / "a" / "workspaces" + board_b = tmp_path / "kanban" / "boards" / "b" / "workspaces" + db.create_session(session_id="a1", source="cli", cwd=str(board_a / "t_a")) + db.create_session(session_id="b1", source="cli", cwd=str(board_b / "t_b")) + + assert db.retag_kanban_worker_sessions(str(board_a)) == 1 + assert db.retag_kanban_worker_sessions(str(board_b)) == 1