This commit is contained in:
HeilbronAILabs 2026-08-14 09:28:06 -04:00 committed by GitHub
commit 74492e1bb2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 222 additions and 2 deletions

View File

@ -3363,13 +3363,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())
@ -3517,6 +3547,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:

View File

@ -155,6 +155,171 @@ 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 same blocked marker",
assignee="default",
initial_status="blocked",
idempotency_key="reuse-key",
)
assert reused_id == old_id
old_task = kb.get_task(conn, old_id)
assert old_task is not None
assert old_task.status == "blocked"
assert kb._has_sticky_block(conn, old_id) is True
assert _event_kinds(conn, old_id) == ["created", "blocked"]
# ---------------------------------------------------------------------------
# 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