fix(agent-team): dispatch via GitHub App so P3 reaches CI (run_id resolves) #63

Merged
amoussa1229 merged 8 commits from fix/agent-team-dispatch-run-id into main 2026-06-24 22:22:19 +00:00
2 changed files with 43 additions and 8 deletions
Showing only changes of commit 7c2303a76c - Show all commits

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