Skip to content
Draft
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
7 changes: 7 additions & 0 deletions apps/web/vercel.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"git": {
"deploymentEnabled": {
"fix/2168-narrow-except-deploy-agents": false
}
}
}
52 changes: 52 additions & 0 deletions docs/EXCEPTION_HANDLING_STANDARDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,55 @@ Remediation is incremental; issue #2168 remains open. The previously prioritized

Before changing a handler, capture its concrete failure mode and caller contract. Use specific exceptions for expected failures; outer-boundary exceptions must log context and propagate an explicit failure. Do not convert security/payment failures into successful defaults. A repository-wide count is insufficient acceptance evidence; attach route/agent failure tests for each repaired critical path.


## Boundary marker

A broad handler (`except Exception`) that is kept on purpose carries an inline marker with its reason, on the `except` line:

```python
except Exception as exc: # exception-boundary: <why this must not raise and what the caller gets instead>
```

A marked boundary logs the context (step/agent/task), the error class, and the traceback, and returns an explicit failure. Unmarked broad handlers are remediation debt.

## Remediated (issue #2168, slice 1)

| File | Before | After | Tests |
| --- | --- | --- | --- |
| `src/agents/mcp_tools/deployment_tool.py` | 4 broad handlers (entry point + git / GitHub repo / Vercel steps); owner-lookup `RuntimeError` swallowed by the GitHub step; entry point printed to stderr | Steps catch only `OSError` and `subprocess.SubprocessError` (incl. `TimeoutExpired`); owner lookup raises `GitHubIdentityError(RuntimeError)`, the only exception caught around it; every failed step logs stage, error class and a token-redacted traceback; one marked boundary at the entry point logs via `logger` and returns `status: "error"` with `error_class` | `tests/unit/test_exception_boundaries_2168.py` |
| `src/youtube_extension/services/agents/adapters/agent_orchestrator.py` | 5 broad handlers | Shared SQL store setup catches only `SQLAlchemyError`, `ImportError` (missing driver) and `OSError` (file); anything else raises. Four marked boundaries (create agent, `execute_task`, `execute_single`, message delivery) log agent/task, error class and traceback; return values unchanged | `tests/unit/test_exception_boundaries_2168.py` |

The test file's AST guard pins the broad-handler count (1 and 4) and requires every one
to carry the marker with a reason. It recognizes tuple catches, multiline catches,
bare handlers and `except*` handlers; line-oriented patterns miss these forms.

## Remaining work

The counts below are historical line-pattern measurements from 2026-11-09 and can
undercount tuple, multiline and `except*` handlers. Recompute broad-handler totals
with AST nodes when assessing remaining work:

```python
import ast
from pathlib import Path

for path in Path("src").rglob("*.py"):
source = path.read_text()
for node in ast.walk(ast.parse(source)):
if not isinstance(node, ast.ExceptHandler):
continue
caught = [] if node.type is None else ast.walk(node.type)
broad = node.type is None or any(
isinstance(t, ast.Name) and t.id in {"Exception", "BaseException"}
for t in caught
)
if broad and "# exception-boundary:" not in source.splitlines()[node.lineno - 1]:
print(f"{path}:{node.lineno}")
```

- `src/agents/` (all modules): 125 unmarked broad handlers.
- `src/youtube_extension/services/agents/adapters/` (other adapters): 12 unmarked broad handlers.
- `src/youtube_extension/services/ai/gemini_service.py`: 22 broad handlers.
- `src/utils/notebooklm_ingest.py`: 5 bare `except:` (10 broad handlers in total).
- `apps/backend/main.py` caption fetch: one `except Exception` around `_fetch_captions`; it already fails closed (returns `success: False`, no fabricated text) but should be narrowed to the provider/transport errors and timeout, or marked as a boundary with logging.
- Ruff `BLE001` (blind except) is not enabled yet; turn it on per module as each one is remediated, starting with the two files above.
130 changes: 95 additions & 35 deletions src/agents/mcp_tools/deployment_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,22 @@
import logging
import os
import subprocess
import traceback
from pathlib import Path
from typing import Any, Optional

logger = logging.getLogger(__name__)

# Failures a deployment step is expected to hit: the CLI is missing or the
# filesystem refuses (OSError), or the subprocess times out / errors
# (subprocess.SubprocessError, which includes TimeoutExpired). Anything else
# is a bug and propagates to the single boundary in deploy_to_github_and_vercel.
STEP_ERRORS: tuple[type[BaseException], ...] = (OSError, subprocess.SubprocessError)


class GitHubIdentityError(RuntimeError):
"""The GitHub owner for a created repo could not be determined."""


class DeploymentMCPTool:
"""
Expand All @@ -34,6 +45,32 @@ async def close(self):
"""Clean up resources"""
pass

def _redact(self, text: str) -> str:
"""Remove configured tokens from text bound for logs or results."""
for secret in (self.github_token, self.vercel_token):
if secret:
# Subprocess exceptions render argv with repr(), escaping e.g.
# backslashes and newlines before the traceback reaches here.
for representation in sorted({secret, repr(secret)[1:-1]}, key=len, reverse=True):
text = text.replace(representation, "***")
return text

def _log_step_failure(self, step: str, exc: BaseException) -> None:
"""Log a failed step with its name, error class and (redacted) traceback.

The traceback is formatted and redacted here rather than passed as
exc_info, because subprocess exceptions embed the full command line,
which carries the Vercel token.
"""
tb = self._redact("".join(traceback.format_exception(type(exc), exc, exc.__traceback__)))
logger.error(
"Deployment step failed: stage=%s error_class=%s\n%s",
step,
type(exc).__name__,
tb,
extra={"stage": step, "error_class": type(exc).__name__, "traceback": tb},
)

async def deploy_to_github_and_vercel(
self,
project_path: str,
Expand All @@ -55,6 +92,7 @@ async def deploy_to_github_and_vercel(
Returns:
Dict with deployment URLs and status
"""
results: dict[str, Any] = {}
try:
logger.info(f"🚀 Deploying: {project_path}")

Expand Down Expand Up @@ -97,7 +135,7 @@ async def deploy_to_github_and_vercel(
# Step 1: Initialize git if needed
git_init_result = await self._initialize_git(project_dir)
if not git_init_result["success"]:
results["errors"].append({"stage": "git_init", "message": git_init_result["error"]})
results["errors"].append(self._stage_error("git_init", git_init_result))
results["status"] = "failed"
return results

Expand All @@ -114,10 +152,7 @@ async def deploy_to_github_and_vercel(
results["github_url"] = github_result["repo_url"]
logger.info(f"✅ GitHub: {results['github_url']}")
else:
results["errors"].append({
"stage": "github",
"message": github_result["error"]
})
results["errors"].append(self._stage_error("github", github_result))
else:
results["errors"].append({
"stage": "github",
Expand All @@ -137,10 +172,7 @@ async def deploy_to_github_and_vercel(
results["vercel_url"] = vercel_result["deployment_url"]
logger.info(f"✅ Vercel: {results['vercel_url']}")
else:
results["errors"].append({
"stage": "vercel",
"message": vercel_result["error"]
})
results["errors"].append(self._stage_error("vercel", vercel_result))
else:
if not self.vercel_token:
results["errors"].append({
Expand All @@ -149,23 +181,32 @@ async def deploy_to_github_and_vercel(
})

# Determine overall status
if results["github_deployed"] or results["vercel_deployed"]:
# A completed GitHub artifact is a partial receipt, not proof that
# the full handoff succeeded when another requested stage failed.
if not results["errors"] and (results["github_deployed"] or results["vercel_deployed"]):
results["status"] = "success"
else:
results["status"] = "failed"

return results

except Exception as e:
logger.error(f"Deployment error: {e}")
import traceback
traceback.print_exc()
except Exception as exc: # exception-boundary: MCP tool entry point; the agent network needs a result dict, never a raise
self._log_step_failure("deploy_entrypoint", exc)
return {
**results,
"status": "error",
"error": str(e),
"error": self._redact(str(exc)) or type(exc).__name__,
"error_class": type(exc).__name__,
"project_path": project_path
Comment thread
groupthinking marked this conversation as resolved.
Resolved
}

@staticmethod
def _stage_error(stage: str, step_result: dict[str, Any]) -> dict[str, Any]:
entry = {"stage": stage, "message": step_result["error"]}
if step_result.get("error_class"):
entry["error_class"] = step_result["error_class"]
return entry

async def _initialize_git(self, project_dir: Path) -> dict[str, Any]:
"""Initialize git repository if not already initialized"""
try:
Expand All @@ -187,7 +228,7 @@ async def _initialize_git(self, project_dir: Path) -> dict[str, Any]:
)

if result.returncode != 0:
return {"success": False, "error": result.stderr}
return {"success": False, "error": self._redact(result.stderr)}

# Create .gitignore if doesn't exist
gitignore = project_dir / ".gitignore"
Expand Down Expand Up @@ -230,10 +271,12 @@ async def _initialize_git(self, project_dir: Path) -> dict[str, Any]:
logger.info("✅ Git initialized")
return {"success": True}

except subprocess.TimeoutExpired:
return {"success": False, "error": "Git initialization timed out"}
except Exception as e:
return {"success": False, "error": str(e)}
except subprocess.TimeoutExpired as exc:
self._log_step_failure("git_init", exc)
return {"success": False, "error": "Git initialization timed out", "error_class": type(exc).__name__}
except STEP_ERRORS as exc:
self._log_step_failure("git_init", exc)
return {"success": False, "error": f"Git initialization failed: {type(exc).__name__}", "error_class": type(exc).__name__}

async def _create_github_repo(
self,
Expand Down Expand Up @@ -270,7 +313,7 @@ async def _create_github_repo(
)

if result.returncode != 0:
error_msg = result.stderr or result.stdout
error_msg = self._redact(result.stderr or result.stdout)
return {"success": False, "error": error_msg}

# Extract repo URL from output
Expand All @@ -286,19 +329,34 @@ async def _create_github_repo(
break

if not repo_url:
# Construct URL from repo name
username = self._get_github_username()
repo_url = f"https://github.com/{github_org or username}/{repo_name}"
# Construct URL from repo name. The org, when given, is the owner;
# otherwise the authenticated user is, and if that cannot be
# looked up the URL is unknown - never guessed.
if github_org:
owner = github_org
else:
try:
owner = self._get_github_username()
except GitHubIdentityError as exc:
self._log_step_failure("github_identity", exc)
return {
"success": False,
"error": "GitHub owner lookup failed; repo URL could not be verified",
"error_class": type(exc).__name__,
}
repo_url = f"https://github.com/{owner}/{repo_name}"

return {
"success": True,
"repo_url": repo_url
}

except subprocess.TimeoutExpired:
return {"success": False, "error": "GitHub repo creation timed out"}
except Exception as e:
return {"success": False, "error": str(e)}
except subprocess.TimeoutExpired as exc:
self._log_step_failure("github", exc)
return {"success": False, "error": "GitHub repo creation timed out", "error_class": type(exc).__name__}
except STEP_ERRORS as exc:
self._log_step_failure("github", exc)
return {"success": False, "error": f"GitHub repo creation failed: {type(exc).__name__}", "error_class": type(exc).__name__}

async def _deploy_to_vercel(
self,
Expand Down Expand Up @@ -331,7 +389,7 @@ async def _deploy_to_vercel(
)

if result.returncode != 0:
error_msg = result.stderr or result.stdout
error_msg = self._redact(result.stderr or result.stdout)
return {"success": False, "error": error_msg}

# Extract deployment URL from output
Expand All @@ -357,10 +415,12 @@ async def _deploy_to_vercel(
"deployment_url": deployment_url
}

except subprocess.TimeoutExpired:
return {"success": False, "error": "Vercel deployment timed out (>5min)"}
except Exception as e:
return {"success": False, "error": str(e)}
except subprocess.TimeoutExpired as exc:
self._log_step_failure("vercel", exc)
return {"success": False, "error": "Vercel deployment timed out (>5min)", "error_class": type(exc).__name__}
except STEP_ERRORS as exc:
self._log_step_failure("vercel", exc)
return {"success": False, "error": f"Vercel deployment failed: {type(exc).__name__}", "error_class": type(exc).__name__}

def _get_github_username(self) -> str:
"""Get GitHub username from gh CLI"""
Expand All @@ -376,9 +436,9 @@ def _get_github_username(self) -> str:
return result.stdout.strip()
except (subprocess.TimeoutExpired, OSError) as exc:
logger.error("GitHub identity lookup failed", extra={"stage": "github_identity", "error_class": type(exc).__name__})
raise RuntimeError("GitHub identity lookup failed") from exc
raise GitHubIdentityError("GitHub identity lookup failed") from exc
logger.error("GitHub identity lookup failed", extra={"stage": "github_identity", "returncode": result.returncode})
raise RuntimeError("GitHub identity lookup failed")
raise GitHubIdentityError("GitHub identity lookup failed")


# Singleton instance
Expand Down
Loading
Loading