fix(agent-team): derive declared_scope from the diff when no plan scope is set

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.
This commit is contained in:
Adam Moussa 2026-06-24 17:56:19 -04:00
parent 4eb245e83b
commit 7c2303a76c
2 changed files with 43 additions and 8 deletions

View file

@ -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:

View file

@ -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