Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 57 additions & 2 deletions hermes_cli/kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -3123,13 +3123,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())

Expand Down Expand Up @@ -3270,6 +3300,31 @@ 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.
#
# 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,
"blocked",
{
"reason": "initial_status=blocked",
"kind": None,
"recurrences": 1,
},
)
_inherit_notify_subs(conn, task_id, parents, created_at=now)
return task_id
except sqlite3.IntegrityError:
Expand Down
167 changes: 167 additions & 0 deletions tests/hermes_cli/test_kanban_blocked_sticky.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,173 @@ 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_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:
"""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
Expand Down