From 3f1904825f4bd1a7231cde18123a6a2637a8262e Mon Sep 17 00:00:00 2001 From: Renn F Date: Wed, 3 Jun 2026 21:13:09 +0200 Subject: [PATCH] fix(gateway): hold pre-assigned dev at give_me_work's list_assigned fallback when deps are unmet --- .../services/gateway/choreographer/_impl.py | 27 ++++++++++++++++++- ...e_assigned_dev_held_on_unmet_dependency.py | 22 ++++++++++++--- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/roboco/services/gateway/choreographer/_impl.py b/roboco/services/gateway/choreographer/_impl.py index da28117d..ba2a235b 100644 --- a/roboco/services/gateway/choreographer/_impl.py +++ b/roboco/services/gateway/choreographer/_impl.py @@ -613,6 +613,29 @@ class Choreographer: return f"call i_will_plan(task_id='{tid}', plan='') to start" return f"call i_will_work_on(task_id='{tid}', plan='') to start" + async def _drop_dependency_held(self, tasks: list[Any]) -> list[Any]: + """Drop pre-assigned PENDING tasks whose non-terminal dependencies are + still unresolved. + + ``give_me_work``'s ``list_assigned_for_agent`` fallback includes PENDING + rows with no dependency filter, so without this a held pre-assigned + subtask (e.g. a frontend dev's task waiting on the UX/UI design) would + still be offered and the agent only bounced at claim time. Mirrors the + gate in ``TaskService.list_pending_for_agent`` and ``_run_claim_guards``. + Only PENDING rows are gated — an already-claimed task is past the gate. + """ + offerable: list[Any] = [] + for task in tasks: + dep_ids = list(getattr(task, "dependency_ids", []) or []) + if ( + str(task.status) == "pending" + and dep_ids + and await self._deps.task.unmet_dependency_ids(dep_ids) + ): + continue + offerable.append(task) + return offerable + async def give_me_work(self, agent_id: UUID) -> Envelope: """Return the agent's most-actionable task or signal idle.""" agent = await self._deps.task.agent_for(agent_id) @@ -632,7 +655,9 @@ class Choreographer: next=self._claim_verb_hint(role, t), context_briefing=await self._briefing_for(agent_id, t.id), ).with_introspection(task=t, role=role) - assigned = await self._deps.task.list_assigned_for_agent(agent_id) + assigned = await self._drop_dependency_held( + await self._deps.task.list_assigned_for_agent(agent_id) + ) if assigned: t = assigned[0] return Envelope.ok( diff --git a/tests/integration/test_pre_assigned_dev_held_on_unmet_dependency.py b/tests/integration/test_pre_assigned_dev_held_on_unmet_dependency.py index 7bb98477..578b062b 100644 --- a/tests/integration/test_pre_assigned_dev_held_on_unmet_dependency.py +++ b/tests/integration/test_pre_assigned_dev_held_on_unmet_dependency.py @@ -274,10 +274,20 @@ async def test_all_three_dev_paths_gate_then_release(dep_gate_setup: dict) -> No assert issue is not None, "spawn validation must hold the dev while UX is unmet" assert "dependency" in issue - # --- (b) give_me_work: list_pending_for_agent --- + # --- (b) give_me_work has TWO offer paths and both must exclude the held + # subtask: list_pending_for_agent (primary) and the + # list_assigned_for_agent fallback (PENDING rows, no built-in dep filter). offered = await svc.list_pending_for_agent(fe_dev_db_id) assert dev_subtask.id not in {t.id for t in offered}, ( - "give_me_work must not offer the dev subtask while UX is unmet" + "list_pending_for_agent must not offer the held dev subtask" + ) + assigned = await svc.list_assigned_for_agent(fe_dev_db_id) + assert dev_subtask.id in {t.id for t in assigned}, ( + "sanity: the held subtask is assigned+pending, so the fallback path is real" + ) + offerable = await choreo._drop_dependency_held(assigned) + assert dev_subtask.id not in {t.id for t in offerable}, ( + "give_me_work's list_assigned_for_agent fallback must not offer it" ) # --- (c) claim: _run_claim_guards (the i_will_work_on guard set) --- @@ -300,7 +310,13 @@ async def test_all_three_dev_paths_gate_then_release(dep_gate_setup: dict) -> No ) offered_after = await svc.list_pending_for_agent(fe_dev_db_id) assert dev_subtask.id in {t.id for t in offered_after}, ( - "give_me_work must offer the dev subtask once UX is terminal" + "list_pending_for_agent must offer the dev subtask once UX is terminal" + ) + offerable_after = await choreo._drop_dependency_held( + await svc.list_assigned_for_agent(fe_dev_db_id) + ) + assert dev_subtask.id in {t.id for t in offerable_after}, ( + "the assigned-fallback gate must release the subtask once UX is terminal" ) released = await svc.get(dev_subtask.id) assert (