Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
103 changes: 50 additions & 53 deletions src/forge/orchestrator/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@

logger = logging.getLogger(__name__)

_CI_STAGES = ("wait_for_ci_gate", "ci_evaluator", "attempt_ci_fix")
_CI_STAGES = ("ci_evaluator", "attempt_ci_fix", "human_review_gate")


def _is_workflow_errored(state: dict) -> bool:
Expand Down Expand Up @@ -555,54 +555,7 @@ async def _handle_resume_event(

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

# An inline reply at the review-response gate applies only to its thread.
# Preserve unrelated contested threads and re-run review analysis so any
# newly accepted item can proceed without globally clearing objections.
if (
message.source == EventSource.GITHUB
and "pull_request_review_comment" in message.event_type
and current_node == "review_response_gate"
and current_state.get("is_paused", True)
):
reply = payload.get("comment", {})
replied_to = reply.get("in_reply_to_id")
sender_login = payload.get("sender", {}).get("login", "")
forge_login = await self._get_forge_github_login()
if sender_login and sender_login == forge_login:
logger.debug("Ignoring Forge's own inline review comment")
return current_state
if replied_to:
contested = current_state.get("contested_comments", [])
remaining = [
item for item in contested if not decision_matches_comment(item, replied_to)
]
return {
**current_state,
"is_paused": False,
"revision_requested": True,
"feedback_comment": reply.get("body", ""),
"contested_comments": remaining,
"context": {
**current_state.get("context", {}),
"resume_event": message.event_type,
"payload": payload,
"review_thread_comment_id": replied_to,
},
}
return {
**current_state,
"is_paused": False,
"revision_requested": True,
"feedback_comment": reply.get("body", ""),
"context": {
**current_state.get("context", {}),
"resume_event": message.event_type,
"payload": payload,
"review_thread_comment_id": reply.get("id"),
},
}

# 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.
Expand All @@ -612,7 +565,7 @@ async def _handle_resume_event(
event = message.event_type
is_check_event = "check_suite" in event or "check_run" in event
if message.source == EventSource.GITHUB and (
current_node in ("wait_for_ci_gate", "ci_evaluator")
current_node == "ci_evaluator"
or (targets_implementation_pr and is_check_event)
):
if is_check_event:
Expand Down Expand Up @@ -1033,6 +986,46 @@ async def _handle_resume_event(
]
is_rejected = True
feedback = body
elif current_node == "review_response_gate" and current_state.get("is_paused", True):
# An inline reply at the review-response gate applies only to its
# thread. Preserve unrelated contested threads and re-run review
# analysis so any newly accepted item can proceed without globally
# clearing objections.
sender_login = payload.get("sender", {}).get("login", "")
forge_login = await self._get_forge_github_login()
if sender_login and sender_login == forge_login:
logger.debug("Ignoring Forge's own inline review comment")
return current_state
if replied_to:
contested = current_state.get("contested_comments", [])
remaining = [
item for item in contested if not decision_matches_comment(item, replied_to)
]
return {
**current_state,
"is_paused": False,
"revision_requested": True,
"feedback_comment": reply.get("body", ""),
"contested_comments": remaining,
"context": {
**current_state.get("context", {}),
"resume_event": message.event_type,
"payload": payload,
"review_thread_comment_id": replied_to,
},
}
return {
**current_state,
"is_paused": False,
"revision_requested": True,
"feedback_comment": reply.get("body", ""),
"context": {
**current_state.get("context", {}),
"resume_event": message.event_type,
"payload": payload,
"review_thread_comment_id": reply.get("id"),
},
}

# GitHub events targeting the PRD proposals PR — handled at prd_approval_gate.
# Merge = approval. Review with feedback = revision. Comment = feedback/question.
Expand Down Expand Up @@ -1477,7 +1470,7 @@ async def _handle_resume_event(
"payload": payload,
},
}
if targets_implementation_pr and is_ci_webhook:
if targets_implementation_pr and is_ci_webhook and current_node != "human_review_gate":
updated_state["current_node"] = "ci_evaluator"
elif targets_implementation_pr and (
"pull_request_review" in message.event_type or pr_merged
Expand Down Expand Up @@ -1586,6 +1579,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 @@ -1705,8 +1704,6 @@ async def _handle_resume_event(
"ci_evaluator",
"attempt_ci_fix",
"human_review_gate",
"wait_for_ci_gate",
"review_response_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 every file this PR touches, compare the branch with the merge base of
the repository's default branch (`{base_branch}`):
```
git diff --name-only "$(git merge-base HEAD origin/{base_branch})" HEAD
```

Do not use only `HEAD~1..HEAD` or `git show HEAD`: those commands omit changes
from earlier commits in a multi-commit pull request.

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.
11 changes: 10 additions & 1 deletion src/forge/sandbox/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,16 @@ def _build_env_vars(
# Pass git configuration for commits
env["GIT_USER_NAME"] = self.settings.git_user_name
env["GIT_USER_EMAIL"] = self.settings.git_user_email
env["CONTAINER_COMMAND_TIMEOUT"] = str(self.settings.container_command_timeout)
# The per-command timeout must not exceed the container's overall lifetime:
# podman kills the container at --timeout (config.timeout_seconds), so a larger
# per-command budget would never fire and would surface as a confusing
# "container killed" instead of a clean "command timed out". Ad-hoc
# ContainerConfig instances (e.g. the smoke test) may set a shorter lifetime
# than settings.container_command_timeout, so clamp here. validate_container_timeouts
# only guards settings-derived configs, not hand-built ones.
env["CONTAINER_COMMAND_TIMEOUT"] = str(
min(self.settings.container_command_timeout, config.timeout_seconds)
)

# Pass Langfuse tracing credentials if enabled
if self.settings.langfuse_enabled:
Expand Down
2 changes: 2 additions & 0 deletions src/forge/workflow/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ class PRIntegrationState(TypedDict, total=False):
pull_requests: dict[str, PullRequestState]
current_pr_url: str | None
current_pr_number: int | None
pr_created_comment_posted: bool
current_repo: str | None
repos_to_process: list[str]
repos_completed: list[str]
Expand All @@ -77,6 +78,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
Loading
Loading