From 15a7268d3037805a360499bd6e23c7f31169943d Mon Sep 17 00:00:00 2001 From: "langsmith-forge[bot]" <270983758+langsmith-forge[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 13:09:54 -0700 Subject: [PATCH] fix: return actionable error when push fails with workflows scope error (#1153) * fix: return actionable error when git push fails due to workflows scope - Root cause: push failure with "workflows scope" error returned a generic "Git push failed: ..." message, causing the agent to retry 10+ times - Change: detect "workflows" + "scope" in push output and return a clear message instructing the agent to remove .github/workflows/ file changes - Verified: unit tests cover both the workflow-scope path and the non-workflow path to prevent regressions * fix: detect github workflow permission push failures * style: ruff-format checkout line in commit_and_open_pr --------- Co-authored-by: LangSmith Forge Co-authored-by: Johannes du Plessis --- agent/tools/commit_and_open_pr.py | 32 ++++- .../test_commit_and_open_pr_workflow_scope.py | 119 ++++++++++++++++++ 2 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 tests/test_commit_and_open_pr_workflow_scope.py diff --git a/agent/tools/commit_and_open_pr.py b/agent/tools/commit_and_open_pr.py index ece7c02a..1b87c4df 100644 --- a/agent/tools/commit_and_open_pr.py +++ b/agent/tools/commit_and_open_pr.py @@ -34,6 +34,16 @@ from ..utils.sandbox_state import get_sandbox_backend_sync logger = logging.getLogger(__name__) +def _is_workflow_scope_push_failure(output: str) -> bool: + normalized_output = output.lower() + return "workflows" in normalized_output and ( + "scope" in normalized_output + or "permission" in normalized_output + or "workflow can be created or updated" in normalized_output + or "create or update workflow" in normalized_output + ) + + def commit_and_open_pr( title: str, body: str, @@ -124,7 +134,11 @@ def commit_and_open_pr( thread_id = configurable.get("thread_id") if not thread_id: - return {"success": False, "error": "Missing thread_id in config", "pr_url": None} + return { + "success": False, + "error": "Missing thread_id in config", + "pr_url": None, + } repo_config = configurable.get("repo", {}) repo_owner = repo_config.get("owner") @@ -138,7 +152,11 @@ def commit_and_open_pr( sandbox_backend = get_sandbox_backend_sync(thread_id) if not sandbox_backend: - return {"success": False, "error": "No sandbox found for thread", "pr_url": None} + return { + "success": False, + "error": "No sandbox found for thread", + "pr_url": None, + } repo_dir = resolve_repo_dir(sandbox_backend, repo_name) github_token = get_github_token() @@ -202,6 +220,16 @@ def commit_and_open_pr( push_result = git_push(sandbox_backend, repo_dir, target_branch) if push_result.exit_code != 0: push_output = push_result.output.strip() + if _is_workflow_scope_push_failure(push_output): + return { + "success": False, + "error": ( + "Git push failed: the branch contains changes to .github/workflows/ files " + "that require the 'workflows' GitHub token scope, which is not available. " + "Remove any .github/workflows/ file changes from your commit and try again." + ), + "pr_url": None, + } if is_permanent_github_push_failure(push_output): return { "success": False, diff --git a/tests/test_commit_and_open_pr_workflow_scope.py b/tests/test_commit_and_open_pr_workflow_scope.py new file mode 100644 index 00000000..00652b61 --- /dev/null +++ b/tests/test_commit_and_open_pr_workflow_scope.py @@ -0,0 +1,119 @@ +"""Tests for workflow-scope push error detection in commit_and_open_pr.""" + +from contextlib import ExitStack +from unittest.mock import MagicMock, patch + +from agent.tools.commit_and_open_pr import commit_and_open_pr + + +def _make_exec_result(exit_code: int, output: str) -> MagicMock: + result = MagicMock() + result.exit_code = exit_code + result.output = output + return result + + +WORKFLOW_SCOPE_OUTPUT = ( + "To https://github.com/langchain-ai/langchainplus.git\n" + " ! [remote rejected] open-swe/abc -> open-swe/abc " + "(Unable to determine if workflow can be created or updated due to timeout; " + "`workflows` scope may be required.)\n" + "error: failed to push some refs to 'https://github.com/langchain-ai/langchainplus.git'\n" +) + + +WORKFLOW_PERMISSION_OUTPUT = ( + "To https://github.com/langchain-ai/open-swe.git\n" + " ! [remote rejected] open-swe/abc -> open-swe/abc " + "(refusing to allow a GitHub App to create or update workflow " + "`.github/workflows/ci.yml` without `workflows` permission)\n" + "error: failed to push some refs to 'https://github.com/langchain-ai/open-swe.git'\n" +) + + +def _run_with_push_result(push_result: MagicMock) -> dict: + """Run commit_and_open_pr with all external calls mocked, using the given push_result.""" + config = { + "configurable": { + "thread_id": "test-thread-id", + "repo": {"owner": "langchain-ai", "name": "open-swe"}, + }, + "metadata": {}, + } + sandbox = MagicMock() + sandbox.execute.return_value = _make_exec_result(0, "") + + patches = [ + patch("agent.tools.commit_and_open_pr.get_config", return_value=config), + patch("agent.tools.commit_and_open_pr.get_sandbox_backend_sync", return_value=sandbox), + patch("agent.tools.commit_and_open_pr.git_has_uncommitted_changes", return_value=True), + patch("agent.tools.commit_and_open_pr.git_fetch_origin"), + patch("agent.tools.commit_and_open_pr.git_has_unpushed_commits", return_value=False), + patch( + "agent.tools.commit_and_open_pr.get_github_app_installation_token", + return_value="token", + ), + patch("agent.tools.commit_and_open_pr.get_github_token", return_value="gh-token"), + patch("agent.tools.commit_and_open_pr.resolve_triggering_user_identity", return_value=None), + patch( + "agent.tools.commit_and_open_pr.add_pr_collaboration_note", + side_effect=lambda body, _: body, + ), + patch( + "agent.tools.commit_and_open_pr.add_user_coauthor_trailer", + side_effect=lambda msg, _: msg, + ), + patch( + "agent.tools.commit_and_open_pr.git_current_branch", + return_value="open-swe/test-thread-id", + ), + patch("agent.tools.commit_and_open_pr.git_config_user"), + patch("agent.tools.commit_and_open_pr.git_add_all"), + patch( + "agent.tools.commit_and_open_pr.git_commit", + return_value=_make_exec_result(0, ""), + ), + patch("agent.tools.commit_and_open_pr.git_push", return_value=push_result), + patch("agent.tools.commit_and_open_pr.asyncio.run", return_value="token"), + ] + + with ExitStack() as stack: + for p in patches: + stack.enter_context(p) + return commit_and_open_pr(title="fix: test", body="## Description\ntest") + + +class TestWorkflowScopePushError: + def test_workflow_scope_error_returns_actionable_message(self): + """Workflow-scope push failure returns a clear message telling the agent to remove + .github/workflows/ files rather than a generic 'Git push failed:' that causes retries.""" + result = _run_with_push_result(_make_exec_result(1, WORKFLOW_SCOPE_OUTPUT)) + + assert result["success"] is False + assert ".github/workflows/" in result["error"] + assert "Remove any .github/workflows/" in result["error"] + assert result["pr_url"] is None + # Must NOT be the raw git output dump that causes blind agent retries + assert "remote rejected" not in result["error"] + + def test_workflow_permission_error_returns_actionable_message(self): + result = _run_with_push_result(_make_exec_result(1, WORKFLOW_PERMISSION_OUTPUT)) + + assert result["success"] is False + assert ".github/workflows/" in result["error"] + assert "Remove any .github/workflows/" in result["error"] + assert result["pr_url"] is None + assert "PERMANENT_FAILURE" not in result["error"] + + def test_non_workflow_push_error_returns_generic_message(self): + """Non-workflow failures that are not permanent auth errors return the generic message.""" + generic_output = ( + "To https://github.com/langchain-ai/open-swe.git\n" + " ! [rejected] open-swe/abc -> open-swe/abc (non-fast-forward)\n" + "error: failed to push some refs\n" + ) + result = _run_with_push_result(_make_exec_result(1, generic_output)) + + assert result["success"] is False + assert result["error"].startswith("Git push failed:") + assert ".github/workflows/" not in result["error"]