From 8db98fb7af536bb7bbb456c3288ad48d2bb251cf Mon Sep 17 00:00:00 2001 From: sopitz Date: Fri, 24 Jul 2026 18:18:10 -0400 Subject: [PATCH 1/2] fix: keep create-time blocked kanban tasks sticky --- hermes_cli/kanban_db.py | 54 +++++++++- .../hermes_cli/test_kanban_blocked_sticky.py | 100 ++++++++++++++++++ 2 files changed, 152 insertions(+), 2 deletions(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 69c9a4992f49e..6ae2d3d67beac 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -2996,13 +2996,43 @@ def create_task( # insert, at which point both rows exist but the next lookup stabilises. if idempotency_key: row = conn.execute( - "SELECT id FROM tasks WHERE idempotency_key = ? " + "SELECT id, status FROM tasks WHERE idempotency_key = ? " "AND status != 'archived' " "ORDER BY created_at DESC LIMIT 1", (idempotency_key,), ).fetchone() if row: - return row["id"] + existing_id = row["id"] + if initial_status == "blocked" and row["status"] == "blocked": + # Idempotent retries are semantically still create_task(...), + # but old rows may have been created before create-time blocked + # rows emitted sticky block events. Repair only the narrow + # same-contract case: caller again asks for initial blocked and + # the reused row is still blocked. This preserves circuit- + # breaker/direct-DB blocked rows unless the caller explicitly + # reuses the create-time blocked contract via idempotency_key. + with write_txn(conn): + current = conn.execute( + "SELECT status FROM tasks " + "WHERE id = ? AND status != 'archived'", + (existing_id,), + ).fetchone() + if ( + current + and current["status"] == "blocked" + and not _has_sticky_block(conn, existing_id) + ): + _append_event( + conn, + existing_id, + "blocked", + { + "reason": "initial_status=blocked:idempotency_reuse_backfill", + "kind": None, + "recurrences": 1, + }, + ) + return existing_id now = int(time.time()) @@ -3141,6 +3171,26 @@ def create_task( "provider_override": provider_override, }, ) + if task_status == "blocked": + # ``initial_status='blocked'`` is advertised as a way to + # create rows that require human/operator action before any + # dispatch. ``recompute_ready()`` distinguishes sticky + # operator blocks from auto-recoverable circuit-breaker + # blocks by the latest blocked/unblocked event, so a + # create-time blocked row must emit the same durable marker + # as an explicit ``kanban block`` call. Without this event, + # the next dispatcher tick can promote a parent-free + # create-time blocked task to ready and select it. + _append_event( + conn, + task_id, + "blocked", + { + "reason": "initial_status=blocked", + "kind": None, + "recurrences": 1, + }, + ) return task_id except sqlite3.IntegrityError: if attempt == 1: diff --git a/tests/hermes_cli/test_kanban_blocked_sticky.py b/tests/hermes_cli/test_kanban_blocked_sticky.py index 2d7cafef826f4..28f1e9e405ea7 100644 --- a/tests/hermes_cli/test_kanban_blocked_sticky.py +++ b/tests/hermes_cli/test_kanban_blocked_sticky.py @@ -271,6 +271,106 @@ def test_protocol_violation_loop_is_broken(kanban_home: Path) -> None: assert kb.get_task(conn, tid).status == "blocked" +# --------------------------------------------------------------------------- +# Create-time blocked rows are also sticky +# --------------------------------------------------------------------------- + + +def _event_kinds(conn, task_id: str) -> list[str]: + return [ + row["kind"] + for row in conn.execute( + "SELECT kind FROM task_events WHERE task_id = ? ORDER BY id", + (task_id,), + ).fetchall() + ] + + +def test_create_time_blocked_task_is_sticky_and_not_claimable(kanban_home: Path) -> None: + """``create_task(initial_status='blocked')`` is a deliberate human-ops + parking state. It must emit the same durable sticky marker as an explicit + ``kanban block`` call, otherwise ``recompute_ready`` can promote it on the + next dispatcher tick and the selector can claim it.""" + with kb.connect() as conn: + marker_id = kb.create_task( + conn, + title="marker-only row", + assignee="default", + initial_status="blocked", + ) + control_id = kb.create_task(conn, title="dispatchable control", assignee="default") + + assert kb.recompute_ready(conn) == 0 + + marker = kb.get_task(conn, marker_id) + control = kb.get_task(conn, control_id) + assert marker is not None + assert marker.status == "blocked" + assert control is not None + assert control.status == "ready" + assert _event_kinds(conn, marker_id) == ["created", "blocked"] + assert kb._has_sticky_block(conn, marker_id) is True + + assert kb.claim_task(conn, marker_id) is None + claimed_control = kb.claim_task(conn, control_id) + assert claimed_control is not None + assert claimed_control.id == control_id + + +def test_idempotent_initial_blocked_reuse_backfills_legacy_nonsticky_row( + kanban_home: Path, +) -> None: + """Retries with the same idempotency key should repair old still-blocked + rows created before initial blocked rows wrote a sticky ``blocked`` event. + The repair is deliberately narrow: the reused row must still be blocked and + the caller must again request ``initial_status='blocked'``.""" + with kb.connect() as conn: + old_id = "t_oldblocked" + conn.execute( + """ + INSERT INTO tasks ( + id, title, assignee, status, created_by, created_at, + workspace_kind, idempotency_key + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + old_id, + "old non-sticky blocked row", + "default", + "blocked", + "legacy-test", + int(time.time()), + "scratch", + "reuse-key", + ), + ) + kb._append_event( + conn, + old_id, + "created", + {"status": "blocked", "assignee": "default", "parents": []}, + ) + conn.commit() + assert kb._has_sticky_block(conn, old_id) is False + + reused_id = kb.create_task( + conn, + title="retry blocked row", + assignee="default", + initial_status="blocked", + idempotency_key="reuse-key", + ) + + assert reused_id == old_id + assert _event_kinds(conn, old_id) == ["created", "blocked"] + assert kb._has_sticky_block(conn, old_id) is True + assert kb.recompute_ready(conn) == 0 + old_task = kb.get_task(conn, old_id) + assert old_task is not None + assert old_task.status == "blocked" + assert kb.claim_task(conn, old_id) is None + + # --------------------------------------------------------------------------- # Schema-init recovery on legacy DBs is covered by # tests/hermes_cli/test_kanban_db.py::test_connect_migrates_legacy_db_before_optional_column_indexes From 560db087f63f55a6ef8bbbd1f3f672cb4badcfab Mon Sep 17 00:00:00 2001 From: sopitz Date: Tue, 4 Aug 2026 02:26:06 -0400 Subject: [PATCH 2/2] fix: advance inherited kanban notify cursors past create-time blocks --- hermes_cli/kanban_db.py | 7 +- .../hermes_cli/test_kanban_blocked_sticky.py | 67 +++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 1980873dc63e5..31d2ad574704f 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -3300,7 +3300,6 @@ def create_task( "provider_override": provider_override, }, ) - _inherit_notify_subs(conn, task_id, parents, created_at=now) if task_status == "blocked": # ``initial_status='blocked'`` is advertised as a way to # create rows that require human/operator action before any @@ -3311,6 +3310,11 @@ def create_task( # as an explicit ``kanban block`` call. Without this event, # the next dispatcher tick can promote a parent-free # create-time blocked task to ready and select it. + # + # Keep this before parent notification inheritance: inherited + # parent-chat subscriptions start at the child's current + # event cursor, and create-time block markers are not future + # child events that should notify the parent after creation. _append_event( conn, task_id, @@ -3321,6 +3325,7 @@ def create_task( "recurrences": 1, }, ) + _inherit_notify_subs(conn, task_id, parents, created_at=now) return task_id except sqlite3.IntegrityError: if attempt == 1: diff --git a/tests/hermes_cli/test_kanban_blocked_sticky.py b/tests/hermes_cli/test_kanban_blocked_sticky.py index 491a94bd2db8e..4ca7fcfc570af 100644 --- a/tests/hermes_cli/test_kanban_blocked_sticky.py +++ b/tests/hermes_cli/test_kanban_blocked_sticky.py @@ -201,6 +201,73 @@ def test_create_time_blocked_task_is_sticky_and_not_claimable(kanban_home: Path) assert claimed_control.id == control_id +def test_initial_blocked_child_inherits_parent_notify_after_block_marker( + kanban_home: Path, +) -> None: + """Parent-chat subscriptions inherited by a create-time blocked child + must start after both the child ``created`` and child ``blocked`` markers. + Otherwise the inherited parent notifier will later replay the child's + create-time blocked marker as if it were a future child event.""" + with kb.connect() as conn: + parent_id = kb.create_task(conn, title="subscribed parent", assignee="default") + conn.execute( + """ + INSERT INTO kanban_notify_subs ( + task_id, platform, chat_id, thread_id, user_id, + notifier_profile, created_at, last_event_id + ) VALUES (?, 'discord', 'parent-chat', '', 'u1', 'notifier', 0, 0) + """, + (parent_id,), + ) + conn.commit() + + child_id = kb.create_task( + conn, + title="blocked child", + assignee="default", + parents=[parent_id], + initial_status="blocked", + ) + + rows = conn.execute( + """ + SELECT id, kind + FROM task_events + WHERE task_id = ? + ORDER BY id + """, + (child_id,), + ).fetchall() + assert [row["kind"] for row in rows] == ["created", "blocked"] + blocked_event_id = next(row["id"] for row in rows if row["kind"] == "blocked") + + sub = conn.execute( + """ + SELECT last_event_id + FROM kanban_notify_subs + WHERE task_id = ? + AND platform = 'discord' + AND chat_id = 'parent-chat' + AND thread_id = '' + """, + (child_id,), + ).fetchone() + assert sub is not None + assert sub["last_event_id"] >= blocked_event_id + + future_inherited_parent_events = conn.execute( + """ + SELECT kind + FROM task_events + WHERE task_id = ? + AND id > ? + ORDER BY id + """, + (child_id, sub["last_event_id"]), + ).fetchall() + assert [row["kind"] for row in future_inherited_parent_events] == [] + + def test_idempotent_initial_blocked_reuse_backfills_legacy_nonsticky_row( kanban_home: Path, ) -> None: