"""Engine tests with fake processors (no LLM): flow, barrier, retry/dead-letter, producer race.""" import asyncio import pytest import kanban from kanban import Flow, Stage, chain_stages, run_flow TOPIC = "t" BOARD = "inventory" def _advance_proc(db, to_stage): async def proc(cards): await db.kanban_advance_many(TOPIC, BOARD, [(c["card_id"], to_stage) for c in cards]) return proc async def _seed(db, n, stage="s1"): for i in range(n): await db.kanban_upsert_card(TOPIC, BOARD, f"card-{i}", "title", stage, {"title": f"T{i}"}) async def test_cards_flow_through_stages(testdb): db = testdb await _seed(db, 7) flow = Flow(TOPIC) stages = chain_stages([ Stage(BOARD, "s1", _advance_proc(db, "s2")), Stage(BOARD, "s2", _advance_proc(db, "done")), ]) await asyncio.wait_for(run_flow(flow, stages), timeout=10) assert await db.kanban_count(TOPIC, "done", board=BOARD) == 7 assert await db.kanban_count(TOPIC, ["s1", "s2"], board=BOARD) == 0 async def test_barrier_waits_for_upstream(testdb): db = testdb await _seed(db, 6) upstream_left: list[int] = [] async def slow_s1(cards): await asyncio.sleep(0.05) # keep upstream busy so an eager barrier would see queued cards await db.kanban_advance_many(TOPIC, BOARD, [(c["card_id"], "gate") for c in cards]) async def barrier_proc(cards): upstream_left.append(await db.kanban_count(TOPIC, ["s1"], board=BOARD)) await db.kanban_advance_many(TOPIC, BOARD, [(c["card_id"], "done") for c in cards]) flow = Flow(TOPIC) stages = chain_stages([ Stage(BOARD, "s1", slow_s1), Stage(BOARD, "gate", barrier_proc, barrier=True), ]) await asyncio.wait_for(run_flow(flow, stages), timeout=10) assert await db.kanban_count(TOPIC, "done", board=BOARD) == 6 assert upstream_left and all(n == 0 for n in upstream_left) # barrier never ran with s1 queued async def test_retry_backoff_then_dead(testdb, monkeypatch): db = testdb monkeypatch.setattr(kanban, "RETRY_BACKOFF", 0.02) await _seed(db, 1) attempts = [] async def failing(cards): attempts.append(cards[0]["retries"]) raise RuntimeError("kaputt") flow = Flow(TOPIC) stages = chain_stages([Stage(BOARD, "s1", failing)]) await asyncio.wait_for(run_flow(flow, stages), timeout=10) card = await db.kanban_get_card(TOPIC, BOARD, "card-0") assert card["stage"] == "dead" assert card["retries"] == kanban.MAX_CARD_RETRIES assert "kaputt" in card["last_error"] assert attempts == [0, 1, 2] # backoff between attempts, then dead-letter async def test_requeue_dead(testdb): db = testdb await db.kanban_upsert_card(TOPIC, BOARD, "card-0", "title", "s1") for _ in range(kanban.MAX_CARD_RETRIES): await db.kanban_fail_card(TOPIC, BOARD, "card-0", "x", kanban.MAX_CARD_RETRIES, 0.0) assert (await db.kanban_get_card(TOPIC, BOARD, "card-0"))["stage"] == "dead" assert await db.kanban_requeue_dead(TOPIC, BOARD, "s1") == 1 card = await db.kanban_get_card(TOPIC, BOARD, "card-0") assert card["stage"] == "s1" and card["retries"] == 0 async def test_producer_attach_in_idle_lull(testdb): """Fix-6 regression: a producer attached while workers sit in the exit grace poll must keep the flow alive and its cards must still be processed.""" db = testdb flow = Flow(TOPIC) stages = chain_stages([Stage(BOARD, "s1", _advance_proc(db, "done"))]) async def producer_a(): await db.kanban_upsert_card(TOPIC, BOARD, "card-a", "title", "s1") flow.wake.set() flow.done_producer() async def attacher(): while await db.kanban_count(TOPIC, "done", board=BOARD) == 0: # wait for card-a done await asyncio.sleep(0.01) flow.add_producer() # synchronous BEFORE the work — the grace poll must see it async def producer_b(): await db.kanban_upsert_card(TOPIC, BOARD, "card-b", "title", "s1") flow.wake.set() flow.done_producer() await producer_b() flow.add_producer() # producer_a, counted before run_flow (sync add) asyncio.get_event_loop().create_task(attacher()) await asyncio.wait_for(run_flow(flow, stages, producers=[producer_a()]), timeout=10) assert await db.kanban_count(TOPIC, "done", board=BOARD) == 2 async def test_backoff_delays_pull(testdb, monkeypatch): db = testdb await db.kanban_upsert_card(TOPIC, BOARD, "card-0", "title", "s1") await db.kanban_fail_card(TOPIC, BOARD, "card-0", "x", 5, 0.2) assert await db.kanban_pull(TOPIC, BOARD, "s1", 10) == [] # in backoff → not pullable assert await db.kanban_count(TOPIC, "s1", board=BOARD) == 1 # but still counts as queued await asyncio.sleep(0.25) assert len(await db.kanban_pull(TOPIC, BOARD, "s1", 10)) == 1