fix(agent-team): dispatch via GitHub App so P3 reaches CI (run_id resolves) #63
2 changed files with 43 additions and 8 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Reference in a new issue