Skip to content
Merged
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 docs/MCP_TOOLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,13 +202,20 @@ List background jobs (indexing, scans, etc.).
- **Args:** None
- **Returns:** Job list with status

Failed jobs also include `error_type` and `traceback` diagnostics. The existing
`errors` list retains its concise messages. Tracebacks are limited to 32,768
characters; oversized values begin with a truncation marker.

### `check_job_status`

Poll a single job.

- **Args:** `job_id` (string)
- **Returns:** Status and progress

Failed jobs expose the same `error_type` and bounded `traceback` fields as
`list_jobs`, so clients can inspect the cause without access to server logs.

---

## Advanced querying
Expand Down
10 changes: 10 additions & 0 deletions docs/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,13 @@ JSON document. Full recipe: `docs/CI_INTEGRATION.md`.
Open an issue with: your `cgc --version`, the backend line from startup, the
exact command, and the full output. The Discord invite in the README is the
fastest route for questions that aren't bugs.

### Failed background indexing jobs

`check_job_status` and `list_jobs` include additive `error_type` and `traceback`
fields for indexing exceptions. The existing `errors[0]` message is preserved.
Tracebacks are capped at 32,768 characters; oversized values start with a
truncation marker and retain the final frames. Jobs presumed stalled have no
exception traceback. These diagnostics are held in memory and disappear after
job cleanup or a server restart. Review tracebacks before sharing them: exception
messages and source lines may contain repository information.
23 changes: 22 additions & 1 deletion src/codegraphcontext/core/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,17 @@
"""
import uuid
import threading
import traceback as traceback_module
from datetime import datetime, timedelta
from dataclasses import dataclass, asdict
from dataclasses import dataclass
from enum import Enum
from typing import Any, Dict, List, Optional
from pathlib import Path


MAX_TRACEBACK_CHARS = 32_768


class JobStatus(Enum):
"""Enumeration for the possible statuses of a background job."""
PENDING = "pending"
Expand Down Expand Up @@ -41,6 +45,8 @@ class JobInfo:
path: Optional[str] = None
is_dependency: bool = False
last_update_time: Optional[datetime] = None
error_type: Optional[str] = None
traceback: Optional[str] = None

def __post_init__(self):
"""Ensures the errors list is initialized after the object is created."""
Expand Down Expand Up @@ -96,6 +102,21 @@ def update_job(self, job_id: str, **kwargs):
setattr(job, key, value)
job.last_update_time = datetime.now()

def fail_job(self, job_id: str, error: Exception, status: JobStatus = JobStatus.FAILED):
"""Record bounded diagnostics while preserving the existing errors[0] contract."""
trace = "".join(traceback_module.format_exception(type(error), error, error.__traceback__))
if len(trace) > MAX_TRACEBACK_CHARS:
marker = "[traceback truncated; showing final frames]\n"
trace = marker + trace[-(MAX_TRACEBACK_CHARS - len(marker)):]
self.update_job(
job_id,
status=status,
end_time=datetime.now(),
errors=[str(error)],
error_type=type(error).__name__,
traceback=trace,
)

def get_job(self, job_id: str) -> Optional[JobInfo]:
"""Retrieves the information for a single job."""
with self.lock:
Expand Down
15 changes: 6 additions & 9 deletions src/codegraphcontext/tools/graph_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@
from __future__ import annotations

import asyncio
import os
from datetime import datetime
import traceback
from pathlib import Path
from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING

Expand All @@ -19,8 +18,8 @@
from ..core.database import DatabaseManager
from ..core.jobs import JobManager, JobStatus
from ..utils.debug_log import debug_log, error_logger, info_logger, warning_logger
from .indexing.constants import DEFAULT_IGNORE_PATTERNS
from .indexing.persistence.writer import GraphWriter, sort_import_rows_for_metadata
from .indexing.constants import DEFAULT_IGNORE_PATTERNS # noqa: F401 - compatibility export
from .indexing.persistence.writer import GraphWriter, sort_import_rows_for_metadata # noqa: F401 - compatibility export
from .indexing.pipeline import run_tree_sitter_index_async
from .indexing.pre_scan import pre_scan_for_imports
from .indexing.resolution.calls import build_function_call_groups, resolve_function_call
Expand Down Expand Up @@ -612,7 +611,7 @@ async def build_graph_from_path_async(
from .scip_indexer import ScipIndexer, detect_project_lang, is_scip_available

scip_langs_str = get_config_value("SCIP_LANGUAGES") or "python,typescript,javascript,go,rust,java,dart,cpp,c,csharp,php,ruby,kotlin,swift,elixir"
scip_languages = [l.strip() for l in scip_langs_str.split(",") if l.strip()]
scip_languages = [language.strip() for language in scip_langs_str.split(",") if language.strip()]
detected_lang = detect_project_lang(path, scip_languages)

if (
Expand Down Expand Up @@ -670,7 +669,7 @@ async def build_graph_from_path_async(
)
except Exception as e:
error_message = str(e)
error_logger(f"Failed to build graph for path {path}: {error_message}")
error_logger(f"Failed to build graph for path {path}: {error_message}\n{traceback.format_exc()}")
if job_id:
if (
"no such file found" in error_message
Expand All @@ -681,9 +680,7 @@ async def build_graph_from_path_async(
else:
status = JobStatus.FAILED

self.job_manager.update_job(
job_id, status=status, end_time=datetime.now(), errors=[str(e)]
)
self.job_manager.fail_job(job_id, e, status=status)

def add_minimal_file_node(self, file_path: Path, repo_path: Path, is_dependency: bool = False) -> None:
"""Create a minimal File node without parsing (for unsupported file types).
Expand Down
9 changes: 4 additions & 5 deletions src/codegraphcontext/tools/indexing/scip_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
import asyncio
import tempfile
import time
import traceback
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Dict, Optional
from typing import Any, Callable, Optional

from ...core.cgcignore import build_ignore_spec
from ...core.jobs import JobManager, JobStatus
Expand Down Expand Up @@ -342,8 +343,6 @@ def should_skip_file(file_path: Path) -> bool:
except RuntimeError:
raise
except Exception as e:
error_logger(f"SCIP indexing failed for {path}: {e}")
error_logger(f"SCIP indexing failed for {path}: {e}\n{traceback.format_exc()}")
if job_id:
job_manager.update_job(
job_id, status=JobStatus.FAILED, end_time=datetime.now(), errors=[str(e)]
)
job_manager.fail_job(job_id, e)
57 changes: 57 additions & 0 deletions tests/unit/core/test_job_failure_diagnostics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import json
from unittest.mock import AsyncMock, patch

import pytest

from codegraphcontext.core.jobs import JobManager, JobStatus, MAX_TRACEBACK_CHARS
from codegraphcontext.tools.graph_builder import GraphBuilder
from codegraphcontext.tools.handlers.management_handlers import check_job_status, list_jobs


def test_failure_keeps_message_and_serializes_traceback():
manager = JobManager()
job_id = manager.create_job('/repo')
try:
raise ValueError('bad index')
except ValueError as error:
manager.fail_job(job_id, error)
job = manager.get_job(job_id)
assert job.status == JobStatus.FAILED
assert job.errors == ['bad index']
assert job.error_type == 'ValueError'
assert 'test_failure_keeps_message' in job.traceback
assert 'ValueError: bad index' in job.traceback
for response in (check_job_status(manager, job_id=job_id), list_jobs(manager)):
assert 'ValueError: bad index' in json.dumps(response)


def test_traceback_is_bounded_and_marks_truncation():
manager = JobManager()
job_id = manager.create_job('/repo')
manager.fail_job(job_id, RuntimeError('x' * (MAX_TRACEBACK_CHARS * 2)))
trace = manager.get_job(job_id).traceback
assert len(trace) == MAX_TRACEBACK_CHARS
assert trace.startswith('[traceback truncated;')


@pytest.mark.asyncio
async def test_indexing_exception_exposes_diagnostics(tmp_path):
manager = JobManager()
job_id = manager.create_job(str(tmp_path))
builder = GraphBuilder.__new__(GraphBuilder)
builder.job_manager = manager
builder.parsers = {}
builder._writer = object()
builder.get_parser = lambda extension: None
builder.parse_file = lambda *args: {}
builder.add_minimal_file_node = lambda *args: None
with patch('codegraphcontext.tools.graph_builder.get_config_value', return_value='false'), patch(
'codegraphcontext.tools.graph_builder.run_tree_sitter_index_async',
new=AsyncMock(side_effect=ValueError('index exploded')),
):
await builder.build_graph_from_path_async(tmp_path, job_id=job_id)
job = manager.get_job(job_id)
assert job.status == JobStatus.FAILED
assert job.errors == ['index exploded']
assert job.error_type == 'ValueError'
assert 'ValueError: index exploded' in job.traceback
Loading