Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
b6210e6
feat: add pending_ci_event state field and update resume map for back…
danchild Jul 13, 2026
1e9e1c3
feat: evaluate_ci_status always clears pending_ci_event
danchild Jul 13, 2026
5decca3
feat: human_review_gate handles initial-entry housekeeping and CI rou…
danchild Jul 13, 2026
2d0e77c
feat: add Phase 0 CI attribution check, update attempt_ci_fix, delete…
danchild Jul 13, 2026
786daac
fix: remove double-increment of ci_fix_attempt in attempt_ci_fix
danchild Jul 13, 2026
2bdd3d6
feat: add shared post-PR lifecycle module with add_post_pr_nodes/edges
danchild Jul 13, 2026
16f8c84
refactor: replace duplicated post-PR wiring with shared add_post_pr_n…
danchild Jul 13, 2026
089a421
fix: restore wait_for_ci_gate→human_review_gate backward compat, dele…
danchild Jul 13, 2026
2402423
fix: replace remaining wait_for_ci_gate references with human_review_…
danchild Jul 13, 2026
0ab1a02
feat: extend worker CI webhook routing to human_review_gate with pend…
danchild Jul 13, 2026
dbb22fa
chore: fix stale comment referencing wait_for_ci_gate in worker
danchild Jul 13, 2026
69bd98a
test: add smoke tests for concurrent CI/review gate
danchild Jul 13, 2026
a6e87f7
fix: remove stale comment, vestigial alias, and GitHubClient resource…
danchild Jul 13, 2026
0e1afec
chore: clean up if statement
danchild Jul 13, 2026
802faa4
chore: refactor _route_after_pr_creation to route_after_pr_creation
danchild Jul 13, 2026
1b3ac78
chore: remove unnecessary comment
danchild Jul 13, 2026
20ba10e
fix: remove scope creep
danchild Jul 13, 2026
24bd358
fix: break backwards compat
danchild Jul 13, 2026
f1637d3
fix: address review findings across CI routing and tests
danchild Jul 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions src/forge/orchestrator/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -467,15 +467,15 @@ async def _handle_resume_event(

current_node = current_state.get("current_node", "")

# GitHub check_run/check_suite events are the explicit signal for wait_for_ci_gate.
# GitHub check_run/check_suite events are the explicit signal for CI evaluation.
# They don't carry Jira labels or comments, so handle them before the label loop.
# For check_suite and check_run events (both real GitHub webhooks and poller
# forwarded ones), only wake up CI evaluation when the suite is completed.
# GitHub fires check_suite webhooks for created/in_progress/completed — evaluating
# on the earlier actions would see a partial set of check runs and could
# prematurely declare success. Other event types (push, pull_request) always wake up.
if (
current_node in ("wait_for_ci_gate", "ci_evaluator")
current_node in ("ci_evaluator", "human_review_gate")
and message.source == EventSource.GITHUB
):
event = message.event_type
Expand All @@ -498,7 +498,7 @@ async def _handle_resume_event(

# GitHub issue_comment events: detect /forge skip-gate and /forge unskip-gate
# commands posted as PR comments.
_CI_STAGES = ("wait_for_ci_gate", "ci_evaluator", "attempt_ci_fix")
_CI_STAGES = ("ci_evaluator", "attempt_ci_fix", "human_review_gate")
if message.source == EventSource.GITHUB and "issue_comment" in message.event_type:
gh_comment_body = payload.get("comment", {}).get("body", "").strip()
repo_full = payload.get("repository", {}).get("full_name", "")
Expand Down Expand Up @@ -1205,6 +1205,12 @@ async def _handle_resume_event(
elif is_ci_webhook:
# GitHub CI event — unpause the gate and let ci_evaluator check the results
updated_state["is_paused"] = False

if current_node == "human_review_gate":
# Keep current_node as human_review_gate so review webhooks arriving
# during the CI cycle are still accepted from the queue.
updated_state["pending_ci_event"] = True

elif is_yolo:
updated_state["yolo_mode"] = True
updated_state["is_paused"] = False
Expand Down Expand Up @@ -1297,7 +1303,6 @@ async def _handle_resume_event(
"ci_evaluator",
"attempt_ci_fix",
"human_review_gate",
"wait_for_ci_gate",
)
if (
not current_state.get("is_paused", True)
Expand Down
50 changes: 50 additions & 0 deletions src/forge/prompts/v1/ci-attribution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
You are analyzing CI failures to determine whether they are caused by changes
in this pull request or by external factors.

## CI Failure Information

The failed checks are described in:
{failures_file_path}

All available log files are in `.forge/logs/`.

## Your Task

Read the failure logs carefully. Compare the failing test names, file paths,
and error messages against the files changed in this PR.

To see which files this PR touches, run:
```
git diff --name-only HEAD~1 HEAD
```
or inspect the most recent commit:
```
git show --stat HEAD
```

Determine whether each failing check is caused by changes introduced in this PR,
or by external factors such as:
- Flaky or intermittently failing tests unrelated to the diff
- Broken test infrastructure (container images, network, environment config)
- Pre-existing failures in files not touched by this PR
- Tests that fail because of unrelated upstream changes

Write your verdict to `.forge/ci-attribution.json` in exactly this format:

```json
{
"attributable": true,
"reason": "one sentence explaining why",
"confidence": "high"
}
```

Set `attributable` to `true` if the failure logs reference files, functions,
or test cases that appear in the PR diff. Set it to `false` if the failures
are clearly unrelated to the diff.

When confidence is low, set `attributable` to `true` (fail-safe: the fix
pipeline will attempt a fix, and the human can always use `/forge skip-gate`
to bypass a stuck check).

Do not attempt to fix anything. Write only the attribution JSON file.
1 change: 1 addition & 0 deletions src/forge/workflow/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ class CIIntegrationState(TypedDict, total=False):
ci_skipped_checks: list[str]
ci_fix_attempt: int
ci_fix_max_attempts: int
pending_ci_event: bool


class ReviewIntegrationState(TypedDict, total=False):
Expand Down
157 changes: 14 additions & 143 deletions src/forge/workflow/bug/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,8 @@
from langgraph.graph import END, StateGraph

from forge.workflow.bug.state import BugState
from forge.workflow.nodes.ci_evaluator import (
attempt_ci_fix,
escalate_to_blocked,
evaluate_ci_status,
wait_for_ci_gate,
)
from forge.workflow.nodes.docs_updater import update_documentation
from forge.workflow.nodes.human_review import (
human_review_gate,
route_human_review,
)
from forge.workflow.nodes.implement_review import (
implement_review,
review_response_gate,
route_review_response,
)
from forge.workflow.nodes.human_review import route_human_review
from forge.workflow.nodes.implementation import implement_task
from forge.workflow.nodes.local_reviewer import local_review_changes
from forge.workflow.nodes.plan_bug_fix import (
Expand All @@ -44,9 +30,13 @@
regenerate_rca,
route_rca_option,
)
from forge.workflow.nodes.rebase import rebase_pr
from forge.workflow.nodes.triage import route_triage_gate, triage_check, triage_gate
from forge.workflow.nodes.workspace_setup import setup_workspace
from forge.workflow.post_pr import (
route_after_pr_creation,
add_post_pr_edges,
add_post_pr_nodes,
)
from forge.workflow.utils import resolve_shared_resume_node

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -129,8 +119,6 @@ def route_entry(state: BugState) -> str:
return "create_pr"
elif current_node == "teardown_workspace":
return "teardown_workspace"
elif current_node == "wait_for_ci_gate":
return "wait_for_ci_gate"
elif current_node == "ai_review":
return "human_review_gate"
elif current_node == "escalate_blocked":
Expand Down Expand Up @@ -354,47 +342,6 @@ def _route_after_implementation(
return "local_review"


def _route_after_pr_creation(
state: BugState,
) -> Literal["teardown_workspace", "escalate_blocked"]:
"""Route after PR creation attempt."""
last_error = state.get("last_error")
pr_urls = state.get("pr_urls", [])

if last_error and not pr_urls:
return "escalate_blocked"

return "teardown_workspace"


def _route_after_teardown(state: BugState) -> str:
"""Route after workspace teardown.

If more repos remain in repos_to_process, loop back to setup_workspace.
Otherwise proceed to wait_for_ci_gate (matching feature workflow pattern)
so that Jira labels are updated and the workflow pauses until CI fires.
"""
repos_to_process = state.get("repos_to_process", [])
repos_completed = state.get("repos_completed", [])
remaining = [r for r in repos_to_process if r not in repos_completed]
if remaining:
return "setup_workspace"
return "wait_for_ci_gate"


def _route_ci_evaluation(
state: BugState,
) -> Literal["human_review_gate", "attempt_ci_fix", "escalate_blocked", "__end__"]:
"""Route based on CI evaluation results."""
ci_status = state.get("ci_status", "")
routes = {
"passed": "human_review_gate",
"fixing": "attempt_ci_fix",
"pending": "__end__",
}
return routes.get(ci_status, "escalate_blocked")


def build_bug_graph() -> StateGraph:
"""Create the Bug workflow graph.

Expand Down Expand Up @@ -450,16 +397,8 @@ def build_bug_graph() -> StateGraph:
graph.add_node("create_pr", create_pull_request)
graph.add_node("teardown_workspace", teardown_and_route)

# ── CI/CD ──
graph.add_node("wait_for_ci_gate", wait_for_ci_gate)
graph.add_node("ci_evaluator", evaluate_ci_status)
graph.add_node("attempt_ci_fix", attempt_ci_fix)
graph.add_node("escalate_blocked", escalate_to_blocked)

# ── Review ──
graph.add_node("human_review_gate", human_review_gate)
graph.add_node("implement_review", implement_review)
graph.add_node("review_response_gate", review_response_gate)
# ── Post-PR nodes (CI/review) - shared across all workflows ──
add_post_pr_nodes(graph)

# ── Set entry point ──
graph.set_entry_point("route_entry")
Expand All @@ -485,7 +424,6 @@ def build_bug_graph() -> StateGraph:
"update_documentation": "update_documentation",
"create_pr": "create_pr",
"teardown_workspace": "teardown_workspace",
"wait_for_ci_gate": "wait_for_ci_gate",
"ci_evaluator": "ci_evaluator",
"human_review_gate": "human_review_gate",
"implement_review": "implement_review",
Expand Down Expand Up @@ -639,86 +577,20 @@ def build_bug_graph() -> StateGraph:
graph.add_edge("update_documentation", "create_pr")
graph.add_conditional_edges(
"create_pr",
_route_after_pr_creation,
route_after_pr_creation,
{
"teardown_workspace": "teardown_workspace",
"escalate_blocked": "escalate_blocked",
},
)
graph.add_conditional_edges(
"teardown_workspace",
_route_after_teardown,
{
"setup_workspace": "setup_workspace", # multi-repo loop-back
"wait_for_ci_gate": "wait_for_ci_gate",
},
)

# ── CI/CD flow ──
graph.add_conditional_edges(
"wait_for_ci_gate",
lambda s: END if s.get("is_paused") else "ci_evaluator",
{END: END, "ci_evaluator": "ci_evaluator"},
)
graph.add_conditional_edges(
"ci_evaluator",
_route_ci_evaluation,
{
"human_review_gate": "human_review_gate",
"attempt_ci_fix": "attempt_ci_fix",
"escalate_blocked": "escalate_blocked",
END: END,
},
)
graph.add_conditional_edges(
"attempt_ci_fix",
lambda s: s.get("current_node", "wait_for_ci_gate"),
{
"wait_for_ci_gate": "wait_for_ci_gate",
"escalate_blocked": "escalate_blocked",
"ci_evaluator": "ci_evaluator",
"attempt_ci_fix": "escalate_blocked",
},
)
graph.add_edge("escalate_blocked", END)

# ── Review flow (merge path → post_merge_summary) ──
# "complete_tasks" is the feature-workflow merge return from route_human_review;
# in the bug graph it should never be reached (pr_merged check intercepts first),
# but map it to post_merge_summary defensively.
graph.add_conditional_edges(
"human_review_gate",
_route_human_review_bug,
{
"implement_review": "implement_review",
"post_merge_summary": "post_merge_summary",
"complete_tasks": "post_merge_summary",
END: END,
},
)
graph.add_conditional_edges(
"implement_review",
lambda s: s.get("current_node", "wait_for_ci_gate"),
{
"wait_for_ci_gate": "wait_for_ci_gate",
"review_response_gate": "review_response_gate",
"implement_review": "implement_review",
"human_review_gate": "human_review_gate",
"escalate_blocked": "escalate_blocked",
},
)
graph.add_conditional_edges(
"review_response_gate",
route_review_response,
{
"implement_review": "implement_review",
"human_review_gate": "human_review_gate",
END: END,
},
# ── Post-PR edges (CI/review) - shared across all workflows ──
add_post_pr_edges(
graph,
on_complete_node="post_merge_summary",
human_review_routing_fn=_route_human_review_bug,
)

# ── Rebase (merge conflict resolution, triggered by /forge rebase) ──
graph.add_node("rebase_pr", rebase_pr)
graph.add_conditional_edges(
"rebase_pr",
lambda s: s.get("current_node", END),
Expand All @@ -732,7 +604,6 @@ def build_bug_graph() -> StateGraph:
"update_documentation": "update_documentation",
"create_pr": "create_pr",
"teardown_workspace": "teardown_workspace",
"wait_for_ci_gate": "wait_for_ci_gate",
"ci_evaluator": "ci_evaluator",
"attempt_ci_fix": "ci_evaluator",
"human_review_gate": "human_review_gate",
Expand Down
Loading
Loading