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.
This commit is contained in:
Brooklyn Nicholson 2026-07-31 14:03:32 -05:00
parent 805c483ca5
commit c2872cf53b
4 changed files with 41 additions and 9 deletions

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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