02005cfe20
`hermes kanban promote --force <id>` printed `Promoted <id> -> ready` and
then the very next claim (a human `claim`, or the dispatcher tick seconds
later) demoted the task back to `todo` with `claim_rejected
{parents_not_done}` and returned None (#106195). The non-force refusal
even pointed operators at `--force` as the escape hatch.
The claim gate is deliberate: `claim_task` is the single enforcement point
("never ready -> running with an undone parent, whichever writer set
'ready'", cda20eec0c), and `complete_task`/`request_review` re-check the
same predicate, so a child let through by a forced claim could still never
finish. A promotion override therefore has no honest outcome; the
dependency edge is the real knob.
- drop `--force` from `promote` (parser, CLI handler, `promote_task`
kwarg, the `forced` event field nothing read)
- the refusal message now states why the gate cannot be bypassed and names
the working remedies: complete the parents or `hermes kanban unlink`
- two invariant tests: refusal on an undone parent leaves `todo` with no
fake `ready`; the flag no longer parses
Salvage direction from #75354 by @vyacheslavk (diagnosis of the promote ->
claim gap); the consume-at-claim authorization there is not taken because
the same parent gate also blocks completion of the forced child.
121 lines
3.9 KiB
Python
121 lines
3.9 KiB
Python
"""Tests for the kanban `promote` verb (issue #28822).
|
|
|
|
The realistic bug scenario from #28822 is: a child task ends up in
|
|
``todo`` with all its parents already ``done`` (because the
|
|
auto-promote daemon hasn't run, or a manual close raced it).
|
|
Direct-SQL setup is used to construct that state deterministically.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from hermes_cli import kanban as kb_cli
|
|
from hermes_cli import kanban_db as kb
|
|
from hermes_cli import kanban_db_connect as kbc
|
|
|
|
|
|
@pytest.fixture
|
|
def kanban_home(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 home
|
|
|
|
|
|
@pytest.fixture
|
|
def conn(kanban_home):
|
|
with kbc.connect() as c:
|
|
yield c
|
|
|
|
|
|
def _stuck_todo(conn, *, parents_done=True, n_parents=1):
|
|
"""Build the #28822 scenario: child in 'todo' whose parents may
|
|
have closed as 'done' without the auto-promote logic firing.
|
|
"""
|
|
parent_ids = [
|
|
kb.create_task(conn, title=f"parent{i}", assignee="setup")
|
|
for i in range(n_parents)
|
|
]
|
|
child_id = kb.create_task(
|
|
conn, title="child", parents=parent_ids, assignee="setup"
|
|
)
|
|
assert kb.get_task(conn, child_id).status == "todo"
|
|
if parents_done:
|
|
for pid in parent_ids:
|
|
conn.execute(
|
|
"UPDATE tasks SET status='done' WHERE id=?", (pid,)
|
|
)
|
|
return child_id, parent_ids
|
|
|
|
|
|
def test_promote_stuck_todo_succeeds(conn):
|
|
child, _ = _stuck_todo(conn, parents_done=True)
|
|
ok, err = kb.promote_task(conn, child, actor="tester")
|
|
assert ok and err is None
|
|
assert kb.get_task(conn, child).status == "ready"
|
|
|
|
|
|
def test_promote_refuses_undone_parent_and_names_the_real_remedy(conn):
|
|
# #106195: promotion must never report a 'ready' that the first claim reverts.
|
|
child, (parent,) = _stuck_todo(conn, parents_done=False)
|
|
ok, err = kb.promote_task(conn, child, actor="tester", reason="recovery")
|
|
assert not ok
|
|
assert parent in err and "--force" not in err and f"unlink <parent_id> {child}" in err
|
|
assert kb.get_task(conn, child).status == "todo"
|
|
assert kb.claim_task(conn, child) is None # still gated; nothing pretended
|
|
|
|
|
|
def test_cli_promote_has_no_force_flag(kanban_home):
|
|
from hermes_cli import kanban_parser
|
|
parser = argparse.ArgumentParser(prog="hermes", add_help=False)
|
|
kanban_parser.build_parser(parser.add_subparsers(dest="command"))
|
|
with pytest.raises(SystemExit):
|
|
parser.parse_args(["kanban", "promote", "t_x", "--force"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI `_cmd_promote` — bulk via `--ids` (the issue's anti-respawn use case:
|
|
# promote all children of a closed parent in one command).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _promote_ns(task_id, *, ids=None, reason=None, force=False,
|
|
dry_run=False, as_json=False):
|
|
return argparse.Namespace(
|
|
task_id=task_id,
|
|
reason=list(reason or []),
|
|
ids=list(ids or []) or None,
|
|
force=force,
|
|
dry_run=dry_run,
|
|
json=as_json,
|
|
)
|
|
|
|
|
|
def test_cli_promote_bulk_ids_promotes_all(kanban_home, capsys):
|
|
with kbc.connect() as conn:
|
|
parent = kb.create_task(conn, title="parent")
|
|
children = [
|
|
kb.create_task(conn, title=f"c{i}", parents=[parent])
|
|
for i in range(3)
|
|
]
|
|
conn.execute("UPDATE tasks SET status='done' WHERE id=?", (parent,))
|
|
rc = kb_cli._cmd_promote(_promote_ns(children[0], ids=children[1:]))
|
|
assert rc == 0
|
|
out = capsys.readouterr().out
|
|
for c in children:
|
|
assert c in out
|
|
with kbc.connect() as conn:
|
|
for c in children:
|
|
assert kb.get_task(conn, c).status == "ready"
|
|
|
|
|