fix(kanban): one undecodable task field no longer breaks the whole board listing

A tasks row whose TEXT body holds invalid UTF-8 made sqlite3 raise
"Could not decode to UTF-8 column 'body'" inside fetchall, so `hermes kanban
list` (and `show`, and every other reader of that row) failed board-wide
until the row was deleted by hand; a BLOB-typed body came back as bytes and
crashed `--json` (#111743).

Fix it once at the connection: every board connection (`_open_configured`
and the read-only descendant path in `connect`) installs a lossy
text_factory that substitutes U+FFFD, and `Task.from_row` runs BLOB cells
through the same helper so a corrupt row renders with replacement
characters instead of taking its neighbours down.

Fixes #111743
This commit is contained in:
teknium1
2026-09-15 12:09:59 -07:00
committed by Teknium
parent 85d4415bed
commit a647c6cb2d
3 changed files with 71 additions and 2 deletions
+15 -2
View File
@@ -33,6 +33,19 @@ _log = logging.getLogger(__name__)
# --- Shared micro-helpers (row access, JSON, env, git) ---
def _lossy_text(value: Any) -> Any:
"""``bytes`` -> ``str`` with U+FFFD for undecodable sequences; anything else passes through.
Installed as every board connection's ``text_factory`` (a TEXT cell holding
invalid UTF-8 otherwise aborts the whole ``fetchall`` with "Could not decode
to UTF-8") and applied to BLOB-typed cells in :meth:`Task.from_row`, so one
corrupt row degrades to replacement characters instead of taking the board
listing down."""
if isinstance(value, bytes):
return value.decode("utf-8", errors="replace")
return value
def _row_get(row: Any, col: str, default: Any = None) -> Any:
"""``row[col]`` tolerant of the column being absent from the SELECT / schema."""
if row is None or col not in row.keys():
@@ -716,11 +729,11 @@ class Task:
@classmethod
def from_row(cls, row: sqlite3.Row) -> "Task":
g = lambda col, default=None: _row_get(row, col, default) # noqa: E731
g = lambda col, default=None: _lossy_text(_row_get(row, col, default)) # noqa: E731
parsed = _json_or(g("skills"))
skills_value = [str(s) for s in parsed if s] if isinstance(parsed, list) else None
return cls(
**{col: row[col] for col in _TASK_REQUIRED_COLUMNS},
**{col: _lossy_text(row[col]) for col in _TASK_REQUIRED_COLUMNS},
**{col: g(col) for col in _TASK_OPTIONAL_COLUMNS},
**{col: g(col) or None for col in _TASK_EMPTY_IS_NULL_COLUMNS},
# Pre-migration fallbacks (spawn_failures / last_spawn_error) are only
+2
View File
@@ -639,6 +639,7 @@ def _open_configured(path: Path, under_lock) -> tuple[sqlite3.Connection, Any]:
conn = _sqlite_connect(path)
try:
conn.row_factory = sqlite3.Row
conn.text_factory = _kb._lossy_text
with _INIT_LOCK:
# WAL doesn't work on network filesystems; the helper falls back to
# DELETE with one ERROR log (see hermes_state_wal._WAL_INCOMPAT_MARKERS).
@@ -678,6 +679,7 @@ def connect(db_path: Optional[Path] = None, *, board: Optional[str] = None) -> s
# missing board or migrate on a descendant's behalf; the owner initializes it.
conn = sqlite3.connect(path.resolve().as_uri() + "?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
conn.text_factory = _kb._lossy_text
if not _schema_is_present(conn):
conn.close()
raise PermissionError("Kanban descendants require an initialized board; ask its owner to initialize it")
@@ -0,0 +1,54 @@
"""Invariant: one task row with an undecodable text field cannot take down the
board listing (issue #111743).
A ``body`` stored as TEXT holding invalid UTF-8 made sqlite3 abort the whole
``fetchall`` ("Could not decode to UTF-8 column 'body'"), so ``hermes kanban list``
failed for every card until the row was deleted by hand; a BLOB-typed body came
back as ``bytes`` and crashed ``--json``. Board connections now decode lossily
(U+FFFD) and ``Task.from_row`` coerces BLOB cells the same way.
"""
from __future__ import annotations
import dataclasses
import json
from pathlib import Path
import pytest
from hermes_cli import kanban_db as kb
from hermes_cli import kanban_db_connect as kbc
@pytest.fixture
def board(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
db_path = kb.kanban_db_path(board="default")
kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
kb.init_db()
return db_path
def test_list_survives_undecodable_and_blob_text_cells(board):
with kbc.connect() as conn:
good = kb.create_task(conn, title="good card", assignee="coder")
conn.execute(
"INSERT INTO tasks (id, title, body, assignee, status, priority, created_by, created_at) "
"VALUES ('t_text_bad', 'text invalid', CAST(X'FFFEABCD00' AS TEXT), 'coder', 'ready', 0, 'user', 1000)"
)
conn.execute(
"INSERT INTO tasks (id, title, body, assignee, status, priority, created_by, created_at) "
"VALUES ('t_blob_bad', 'blob body', X'FFFEABCD00', 'coder', 'ready', 0, 'user', 1000)"
)
with kbc.connect() as conn:
tasks = {t.id: t for t in kb.list_tasks(conn, status="ready")}
assert {good, "t_text_bad", "t_blob_bad"} <= set(tasks)
for tid in ("t_text_bad", "t_blob_bad"):
body = tasks[tid].body
assert isinstance(body, str) and "\ufffd" in body
json.dumps(dataclasses.asdict(tasks[tid])) # the --json path
assert kb.get_task(conn, "t_text_bad").title == "text invalid"