From 7c2303a76c118d87c61a6cefa8fb0cfa9ff2c55e Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Wed, 24 Jun 2026 17:56:19 -0400 Subject: [PATCH] fix(agent-team): derive declared_scope from the diff when no plan scope is set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end validation surfaced that auto-dispatch parked every task at "empty declared_scope": dispatch_node required plan["scope"], but the planner emits only summary+phases (never a scope) and config.allowed_scope defaults to None, so no node ever populated it. (The earlier dispatch test was operator-initiated with an explicit scope; the auto planner->build->dispatch path was never exercised until box-side App dispatch went live.) Fix: when no planner-/operator-declared scope is present, dispatch_node derives declared_scope from the candidate diff's own touched paths (ci_gate.diff_touched_paths). This supplies the missing scope without relaxing any CI trust control — the apply/verify workflow still INDEPENDENTLY re-checks the materialized diff against the denylist + '..'-escape + this scope + the diff-hash binding, and the agent-apply environment's required reviewer remains the human gate. A non-empty diff that parses to zero touched paths still parks (fail closed). Tests: the three old park-on-missing-scope cases now assert scope-from-diff dispatch; added a park case for a diff with no parseable paths. 1527 pass. --- .../agent_team/nodes/dispatch_invoker.py | 15 +++++++- agent-team/tests/test_ws3_dispatch_invoker.py | 36 +++++++++++++++---- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/agent-team/agent_team/nodes/dispatch_invoker.py b/agent-team/agent_team/nodes/dispatch_invoker.py index 6c9c1da..9815d22 100644 --- a/agent-team/agent_team/nodes/dispatch_invoker.py +++ b/agent-team/agent_team/nodes/dispatch_invoker.py @@ -62,6 +62,7 @@ def make_dispatch_node( full graph state, so the coordinator can ALARM and a human can inspect. """ # Deferred import: no orchestrator / subprocess module at module load. + from agent_team.ci_gate import diff_touched_paths from agent_team.dispatcher import DispatcherError, dispatch_apply_verify from agent_team.task_model import Phase, TaskStatus @@ -83,7 +84,19 @@ def make_dispatch_node( _LOG.warning("dispatch_node: missing thread_id or candidate_diff; parking") return _parked if not declared_scope.strip(): - _LOG.warning("dispatch_node: empty declared_scope from plan; parking") + # No planner-/operator-declared scope (the planner emits only + # summary+phases, never a scope) — derive an HONEST, non-empty + # declared_scope from the candidate diff's own touched paths. The + # workflow's guard still INDEPENDENTLY re-checks the materialized diff + # against the denylist + '..'-escape + this scope + the diff-hash + # binding, and the `agent-apply` environment's required reviewer remains + # the human gate — so this only supplies the scope that was missing, it + # does not relax any CI trust control. + declared_scope = "\n".join(diff_touched_paths(diff_text)) + if not declared_scope.strip(): + _LOG.warning( + "dispatch_node: no declared scope and diff touches no paths; parking" + ) return _parked try: diff --git a/agent-team/tests/test_ws3_dispatch_invoker.py b/agent-team/tests/test_ws3_dispatch_invoker.py index 22dda10..20a63bc 100644 --- a/agent-team/tests/test_ws3_dispatch_invoker.py +++ b/agent-team/tests/test_ws3_dispatch_invoker.py @@ -274,7 +274,10 @@ def test_dispatch_node_parks_on_whitespace_only_diff() -> None: assert not disp_calls -def test_dispatch_node_parks_on_empty_scope() -> None: +def test_dispatch_node_derives_scope_from_diff_when_plan_scope_empty() -> None: + # The planner never emits a scope, so an empty plan scope must NOT park: the + # node derives declared_scope from the candidate diff's touched paths + # (_VALID_STATE's diff touches f.py) and dispatches. _, pusher = _make_fake_pusher() disp_calls, dispatcher = _make_fake_workflow_dispatcher() node = make_dispatch_node( @@ -284,11 +287,12 @@ def test_dispatch_node_parks_on_empty_scope() -> None: state = dict(_VALID_STATE, plan={"scope": []}) result = node(state) - assert result.get("status") == TaskStatus.PARKED.value - assert not disp_calls + assert result.get("status") != TaskStatus.PARKED.value + assert len(disp_calls) == 1 + assert disp_calls[0]["inputs"]["declared_scope"] == "f.py" -def test_dispatch_node_parks_on_none_plan() -> None: +def test_dispatch_node_derives_scope_from_diff_when_plan_none() -> None: _, pusher = _make_fake_pusher() disp_calls, dispatcher = _make_fake_workflow_dispatcher() node = make_dispatch_node( @@ -298,11 +302,11 @@ def test_dispatch_node_parks_on_none_plan() -> None: state = dict(_VALID_STATE, plan=None) result = node(state) - assert result.get("status") == TaskStatus.PARKED.value - assert not disp_calls + assert result.get("status") != TaskStatus.PARKED.value + assert disp_calls[0]["inputs"]["declared_scope"] == "f.py" -def test_dispatch_node_parks_on_non_dict_plan() -> None: +def test_dispatch_node_derives_scope_from_diff_when_plan_non_dict() -> None: _, pusher = _make_fake_pusher() disp_calls, dispatcher = _make_fake_workflow_dispatcher() node = make_dispatch_node( @@ -312,6 +316,24 @@ def test_dispatch_node_parks_on_non_dict_plan() -> None: state = dict(_VALID_STATE, plan="not-a-dict") result = node(state) + assert result.get("status") != TaskStatus.PARKED.value + assert disp_calls[0]["inputs"]["declared_scope"] == "f.py" + + +def test_dispatch_node_parks_when_no_scope_and_diff_touches_no_paths() -> None: + # The genuine park case: no plan scope AND a (non-empty) diff from which no + # touched path can be parsed -> nothing honest to declare -> fail closed. + _, pusher = _make_fake_pusher() + disp_calls, dispatcher = _make_fake_workflow_dispatcher() + node = make_dispatch_node( + owner="org", repo="repo", pusher=pusher, dispatcher=dispatcher + ) + + state = dict( + _VALID_STATE, plan={"scope": []}, candidate_diff="not a real diff, no headers\n" + ) + result = node(state) + assert result.get("status") == TaskStatus.PARKED.value assert not disp_calls