ci: log analyzer (#22859)
This commit is contained in:
@@ -153,6 +153,27 @@ jobs:
|
|||||||
retention-days: 30
|
retention-days: 30
|
||||||
if-no-files-found: warn
|
if-no-files-found: warn
|
||||||
|
|
||||||
|
- name: Analyze logs with AI on failure
|
||||||
|
if: failure()
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
MODAL_TOKEN_ID: ${{ secrets.NV_MODAL_TOKEN_ID }}
|
||||||
|
MODAL_TOKEN_SECRET: ${{ secrets.NV_MODAL_TOKEN_SECRET }}
|
||||||
|
run: |
|
||||||
|
TARBALL="${{ github.workspace }}/multinode_server_logs.tar.gz"
|
||||||
|
if [ -f "$TARBALL" ]; then
|
||||||
|
uv run --with modal python scripts/ci/slurm/analyze_logs_with_modal.py \
|
||||||
|
--tarball "$TARBALL" \
|
||||||
|
--job-id "${{ matrix.config.name }}-${{ github.run_id }}" \
|
||||||
|
--output "${{ github.workspace }}/ai_analysis.md"
|
||||||
|
if [ -f "${{ github.workspace }}/ai_analysis.md" ]; then
|
||||||
|
echo "## AI Log Analysis" >> $GITHUB_STEP_SUMMARY
|
||||||
|
cat "${{ github.workspace }}/ai_analysis.md" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "No log tarball found, skipping analysis"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Clean up Slurm jobs on failure/cancel
|
- name: Clean up Slurm jobs on failure/cancel
|
||||||
if: failure() || cancelled()
|
if: failure() || cancelled()
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Analyze srtslurm logs with opencode inside a Modal sandbox.
|
||||||
|
|
||||||
|
This script accepts either:
|
||||||
|
- a local log directory
|
||||||
|
- a `.tar.gz` bundle such as `multinode_server_logs.tar.gz`
|
||||||
|
|
||||||
|
It uploads the logs into an ephemeral Modal sandbox, installs and runs
|
||||||
|
opencode with an analysis prompt, and prints the resulting markdown.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
uv run --with modal python scripts/ci/slurm/analyze_logs_with_modal.py \
|
||||||
|
--tarball /tmp/multinode_server_logs.tar.gz \
|
||||||
|
--job-id 4645
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
import shutil
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
import modal
|
||||||
|
except ImportError: # pragma: no cover - runtime guard for local usage
|
||||||
|
modal = None
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("slurm_log_analysis")
|
||||||
|
|
||||||
|
SANDBOX_TIMEOUT = 600
|
||||||
|
DEFAULT_MODAL_SECRET_NAME = "or"
|
||||||
|
DEFAULT_MODEL = "openrouter/minimax/minimax-m2.7"
|
||||||
|
DEFAULT_REPOS = [
|
||||||
|
"https://github.com/sgl-project/sglang.git",
|
||||||
|
]
|
||||||
|
PROMPT_PATH = Path(__file__).with_name("log_analysis_prompt.md")
|
||||||
|
|
||||||
|
# Matches common API key / token patterns (sk-..., ak-..., as-..., key-..., etc.)
|
||||||
|
_SECRET_PATTERN = re.compile(
|
||||||
|
r"""(?:"""
|
||||||
|
r"""(?:sk|ak|as|key|token|secret|bearer)[-_][A-Za-z0-9_\-]{16,}"""
|
||||||
|
r"""|"""
|
||||||
|
r"""(?:OPENROUTER_API_KEY|MODAL_TOKEN_ID|MODAL_TOKEN_SECRET|ANTHROPIC_API_KEY)"""
|
||||||
|
r"""[=:]\s*\S+"""
|
||||||
|
r""")""",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize(text: str) -> str:
|
||||||
|
"""Redact strings that look like API keys or secrets."""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
sanitized = _SECRET_PATTERN.sub("[REDACTED]", text)
|
||||||
|
# Also redact any env var values we know are secrets
|
||||||
|
for var in ("OPENROUTER_API_KEY", "MODAL_TOKEN_ID", "MODAL_TOKEN_SECRET"):
|
||||||
|
val = os.environ.get(var)
|
||||||
|
if val and len(val) > 8:
|
||||||
|
sanitized = sanitized.replace(val, "[REDACTED]")
|
||||||
|
return sanitized
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging(verbose: bool) -> None:
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(levelname)s: %(message)s",
|
||||||
|
)
|
||||||
|
logger.setLevel(logging.DEBUG if verbose else logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_tarball(tarball: Path, destination: Path) -> None:
|
||||||
|
with tarfile.open(tarball, "r:gz") as archive:
|
||||||
|
# Python 3.14 changes the default extraction behavior. Use the
|
||||||
|
# data filter when available so extraction remains explicit.
|
||||||
|
if "data" in tarfile._NAMED_FILTERS: # type: ignore[attr-defined]
|
||||||
|
archive.extractall(destination, filter="data")
|
||||||
|
else:
|
||||||
|
archive.extractall(destination)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Analyze a srtslurm log bundle with opencode in Modal."
|
||||||
|
)
|
||||||
|
source = parser.add_mutually_exclusive_group(required=True)
|
||||||
|
source.add_argument(
|
||||||
|
"--tarball",
|
||||||
|
type=Path,
|
||||||
|
help="Path to a local multinode_server_logs.tar.gz bundle.",
|
||||||
|
)
|
||||||
|
source.add_argument(
|
||||||
|
"--log-dir",
|
||||||
|
type=Path,
|
||||||
|
help="Path to an unpacked log directory.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--job-id",
|
||||||
|
default="unknown",
|
||||||
|
help="Job identifier used in the report header and logs.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--model",
|
||||||
|
default=DEFAULT_MODEL,
|
||||||
|
help="Model selector to pass to opencode run.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
type=Path,
|
||||||
|
help="Optional path to write the markdown analysis.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--repo-url",
|
||||||
|
action="append",
|
||||||
|
dest="repo_urls",
|
||||||
|
help=(
|
||||||
|
"Optional extra repo URL to clone into the sandbox for context. "
|
||||||
|
"Can be specified multiple times."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--timeout-seconds",
|
||||||
|
type=int,
|
||||||
|
default=SANDBOX_TIMEOUT,
|
||||||
|
help="Sandbox lifetime in seconds.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--modal-secret-name",
|
||||||
|
default=DEFAULT_MODAL_SECRET_NAME,
|
||||||
|
help="Modal secret name that provides OPENROUTER_API_KEY to the sandbox.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--verbose",
|
||||||
|
action="store_true",
|
||||||
|
help="Enable debug logging.",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def build_sandbox_image() -> "modal.Image":
|
||||||
|
if modal is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"The 'modal' package is required. Run this script with "
|
||||||
|
"`uv run --with modal python ...` or install modal locally."
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
modal.Image.debian_slim(python_version="3.12")
|
||||||
|
.apt_install("bash", "curl", "git", "gh")
|
||||||
|
.run_commands(
|
||||||
|
"curl -fsSL https://opencode.ai/install | bash",
|
||||||
|
)
|
||||||
|
.env(
|
||||||
|
{
|
||||||
|
"PATH": "/root/.opencode/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_log_dir(args: argparse.Namespace) -> tuple[Path, Path | None]:
|
||||||
|
if args.log_dir:
|
||||||
|
if not args.log_dir.is_dir():
|
||||||
|
raise FileNotFoundError(f"log directory not found: {args.log_dir}")
|
||||||
|
return args.log_dir.resolve(), None
|
||||||
|
|
||||||
|
assert args.tarball is not None
|
||||||
|
if not args.tarball.is_file():
|
||||||
|
raise FileNotFoundError(f"tarball not found: {args.tarball}")
|
||||||
|
|
||||||
|
temp_dir = Path(tempfile.mkdtemp(prefix="sglang_logs_"))
|
||||||
|
extract_tarball(args.tarball, temp_dir)
|
||||||
|
return temp_dir.resolve(), temp_dir
|
||||||
|
|
||||||
|
|
||||||
|
def build_prompt(job_id: str, repo_urls: list[str]) -> str:
|
||||||
|
skill_content = PROMPT_PATH.read_text()
|
||||||
|
repo_lines = []
|
||||||
|
for repo_url in repo_urls:
|
||||||
|
repo_name = repo_url.rsplit("/", 1)[-1].removesuffix(".git")
|
||||||
|
repo_lines.append(f"- **{repo_name} repo**: `/workspace/repos/{repo_name}/`")
|
||||||
|
repo_section = (
|
||||||
|
"\n".join(repo_lines) if repo_lines else "- No extra repos were requested."
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"""{skill_content}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Your Environment
|
||||||
|
|
||||||
|
- **Logs**: `/workspace/logs/`
|
||||||
|
- **GitHub CLI**: `gh` is installed and authenticated if `GH_TOKEN` is present
|
||||||
|
{repo_section}
|
||||||
|
|
||||||
|
## Task
|
||||||
|
|
||||||
|
Analyze the log bundle for job `{job_id}`.
|
||||||
|
|
||||||
|
1. Read the logs in the priority order described above.
|
||||||
|
2. Correlate timestamps to identify the most likely root cause.
|
||||||
|
3. Use `gh` to inspect related PRs or commits if it materially helps.
|
||||||
|
4. **You MUST write the final markdown report to `/workspace/logs/ai_analysis.md`.**
|
||||||
|
This is a hard requirement. Do not just print the report to stdout.
|
||||||
|
Use your file-writing tool to create `/workspace/logs/ai_analysis.md` with the
|
||||||
|
full analysis. The downstream pipeline reads this file.
|
||||||
|
|
||||||
|
Keep the report concise but concrete. Include evidence, likely root cause, and notable noise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def upload_tree(sandbox: "modal.Sandbox", log_dir: Path) -> None:
|
||||||
|
log_files = [path for path in log_dir.rglob("*") if path.is_file()]
|
||||||
|
logger.info("Uploading %d log files into the sandbox", len(log_files))
|
||||||
|
for index, log_file in enumerate(log_files, start=1):
|
||||||
|
rel_path = log_file.relative_to(log_dir)
|
||||||
|
remote_path = str(Path("/workspace/logs") / rel_path)
|
||||||
|
sandbox.mkdir(str(Path(remote_path).parent), parents=True)
|
||||||
|
sandbox.filesystem.write_bytes(log_file.read_bytes(), remote_path)
|
||||||
|
if index % 10 == 0 or index == len(log_files):
|
||||||
|
logger.info("Uploaded %d/%d files", index, len(log_files))
|
||||||
|
|
||||||
|
|
||||||
|
def clone_context_repos(sandbox: "modal.Sandbox", repo_urls: list[str]) -> None:
|
||||||
|
if not repo_urls:
|
||||||
|
return
|
||||||
|
|
||||||
|
sandbox.mkdir("/workspace/repos", parents=True)
|
||||||
|
|
||||||
|
for repo_url in repo_urls:
|
||||||
|
repo_name = repo_url.rsplit("/", 1)[-1].removesuffix(".git")
|
||||||
|
logger.info("Cloning %s into the sandbox", repo_name)
|
||||||
|
sandbox.exec(
|
||||||
|
"git",
|
||||||
|
"clone",
|
||||||
|
"--depth",
|
||||||
|
"100",
|
||||||
|
repo_url,
|
||||||
|
f"/workspace/repos/{repo_name}",
|
||||||
|
).wait()
|
||||||
|
|
||||||
|
|
||||||
|
def read_optional_file(sandbox: "modal.Sandbox", path: str) -> str | None:
|
||||||
|
try:
|
||||||
|
return sandbox.filesystem.read_text(path)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def run_opencode_analysis(
|
||||||
|
*,
|
||||||
|
log_dir: Path,
|
||||||
|
job_id: str,
|
||||||
|
model: str,
|
||||||
|
repo_urls: list[str],
|
||||||
|
timeout_seconds: int,
|
||||||
|
modal_secret_name: str,
|
||||||
|
) -> str:
|
||||||
|
prompt = build_prompt(job_id, repo_urls)
|
||||||
|
app = modal.App.lookup("sglang-log-analyzer", create_if_missing=True)
|
||||||
|
sandbox = modal.Sandbox.create(
|
||||||
|
app=app,
|
||||||
|
image=build_sandbox_image(),
|
||||||
|
timeout=timeout_seconds,
|
||||||
|
secrets=[modal.Secret.from_name(modal_secret_name)],
|
||||||
|
)
|
||||||
|
logger.info("Created Modal sandbox %s", sandbox.object_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
sandbox.mkdir("/workspace/logs", parents=True)
|
||||||
|
sandbox.mkdir("/workspace/repos", parents=True)
|
||||||
|
|
||||||
|
clone_context_repos(sandbox, repo_urls)
|
||||||
|
upload_tree(sandbox, log_dir)
|
||||||
|
|
||||||
|
sandbox.filesystem.write_text(prompt, "/workspace/prompt.txt")
|
||||||
|
|
||||||
|
runner_script = f"""#!/bin/bash
|
||||||
|
set -uo pipefail
|
||||||
|
cd /workspace
|
||||||
|
if opencode run \\
|
||||||
|
--dangerously-skip-permissions \\
|
||||||
|
--dir /workspace/logs \\
|
||||||
|
-m {shlex.quote(model)} \\
|
||||||
|
"$(cat /workspace/prompt.txt)" \\
|
||||||
|
< /dev/null \\
|
||||||
|
> /workspace/logs/opencode.stdout \\
|
||||||
|
2> /workspace/logs/opencode.stderr; then
|
||||||
|
echo 0 > /workspace/logs/opencode.exitcode
|
||||||
|
else
|
||||||
|
echo $? > /workspace/logs/opencode.exitcode
|
||||||
|
fi
|
||||||
|
ls -la /workspace/logs > /workspace/logs/log_dir_listing.txt
|
||||||
|
"""
|
||||||
|
sandbox.filesystem.write_text(runner_script, "/workspace/run_opencode.sh")
|
||||||
|
sandbox.exec("chmod", "+x", "/workspace/run_opencode.sh").wait()
|
||||||
|
|
||||||
|
logger.info("Running opencode analysis")
|
||||||
|
process = sandbox.exec(
|
||||||
|
"bash",
|
||||||
|
"/workspace/run_opencode.sh",
|
||||||
|
)
|
||||||
|
process.wait()
|
||||||
|
|
||||||
|
stderr = process.stderr.read()
|
||||||
|
if stderr:
|
||||||
|
logger.warning("runner stderr: %s", sanitize(stderr[:500]))
|
||||||
|
|
||||||
|
exitcode = read_optional_file(sandbox, "/workspace/logs/opencode.exitcode")
|
||||||
|
opencode_stdout = (
|
||||||
|
read_optional_file(sandbox, "/workspace/logs/opencode.stdout") or ""
|
||||||
|
)
|
||||||
|
opencode_stderr = read_optional_file(sandbox, "/workspace/logs/opencode.stderr")
|
||||||
|
log_dir_listing = read_optional_file(
|
||||||
|
sandbox, "/workspace/logs/log_dir_listing.txt"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
ai_analysis = read_optional_file(sandbox, "/workspace/logs/ai_analysis.md")
|
||||||
|
if ai_analysis and ai_analysis.strip():
|
||||||
|
return sanitize(ai_analysis)
|
||||||
|
|
||||||
|
if opencode_stdout.strip():
|
||||||
|
return sanitize(opencode_stdout)
|
||||||
|
|
||||||
|
raise RuntimeError("opencode completed without producing analysis output")
|
||||||
|
except Exception as exc:
|
||||||
|
stdout = process.stdout.read()
|
||||||
|
if stdout:
|
||||||
|
return sanitize(stdout)
|
||||||
|
details = [
|
||||||
|
f"opencode analysis did not produce a usable report: {exc}",
|
||||||
|
f"exitcode={exitcode!r}",
|
||||||
|
f"stdout_preview={sanitize(opencode_stdout[:500])!r}",
|
||||||
|
f"stderr_preview={sanitize((opencode_stderr or '')[:500])!r}",
|
||||||
|
f"log_dir_listing={sanitize((log_dir_listing or '')[:500])!r}",
|
||||||
|
]
|
||||||
|
raise RuntimeError(" ".join(details)) from exc
|
||||||
|
finally:
|
||||||
|
sandbox.terminate()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
configure_logging(args.verbose)
|
||||||
|
|
||||||
|
repo_urls = list(DEFAULT_REPOS)
|
||||||
|
if args.repo_urls:
|
||||||
|
repo_urls.extend(args.repo_urls)
|
||||||
|
|
||||||
|
log_dir, cleanup_dir = prepare_log_dir(args)
|
||||||
|
try:
|
||||||
|
analysis = run_opencode_analysis(
|
||||||
|
log_dir=log_dir,
|
||||||
|
job_id=args.job_id,
|
||||||
|
model=args.model,
|
||||||
|
repo_urls=repo_urls,
|
||||||
|
timeout_seconds=args.timeout_seconds,
|
||||||
|
modal_secret_name=args.modal_secret_name,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if cleanup_dir is not None:
|
||||||
|
shutil.rmtree(cleanup_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
print(analysis)
|
||||||
|
if args.output:
|
||||||
|
args.output.write_text(analysis)
|
||||||
|
logger.info("Wrote analysis to %s", args.output)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# srtslurm Log Analysis
|
||||||
|
|
||||||
|
You are analyzing logs from a failed srtslurm job. srtslurm is a Python-first
|
||||||
|
orchestration framework for running distributed LLM inference benchmarks on
|
||||||
|
SLURM clusters using SGLang and TRTLLM backends.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
1. List the directory contents to understand what files are present.
|
||||||
|
2. Read files in priority order.
|
||||||
|
3. Correlate timestamps to identify the real failure point.
|
||||||
|
4. Distinguish root cause from noisy warnings.
|
||||||
|
|
||||||
|
## Priority Order
|
||||||
|
|
||||||
|
### 1. `sweep_{job_id}.log`
|
||||||
|
|
||||||
|
Read this first. It is the orchestration timeline.
|
||||||
|
|
||||||
|
Look for:
|
||||||
|
- stage transitions
|
||||||
|
- worker readiness
|
||||||
|
- benchmark start
|
||||||
|
- exit codes
|
||||||
|
- the last error before teardown
|
||||||
|
|
||||||
|
### 2. `benchmark.out`
|
||||||
|
|
||||||
|
If present, this usually contains the benchmark-side exception or timeout.
|
||||||
|
|
||||||
|
### 3. `artifacts/*/logs/aiperf_*.log`
|
||||||
|
|
||||||
|
If present, these often contain framework-level initialization failures and
|
||||||
|
HTTP/network issues.
|
||||||
|
|
||||||
|
### 4. Worker logs
|
||||||
|
|
||||||
|
Focus on errors that line up with the failure timestamp:
|
||||||
|
- `{node}_prefill_w{N}.out`
|
||||||
|
- `{node}_decode_w{N}.out`
|
||||||
|
- `{node}_frontend_{N}.out`
|
||||||
|
|
||||||
|
### 5. `infra.out`
|
||||||
|
|
||||||
|
Use this to confirm infrastructure failures involving NATS, etcd, ports, or
|
||||||
|
service health checks.
|
||||||
|
|
||||||
|
## Timestamp Correlation
|
||||||
|
|
||||||
|
This is the most important rule.
|
||||||
|
|
||||||
|
Many warnings are harmless. The root cause is usually the error that occurs at
|
||||||
|
the same time the orchestration log transitions into failure.
|
||||||
|
|
||||||
|
Use this method:
|
||||||
|
1. Find the failure time in `sweep_{job_id}.log`.
|
||||||
|
2. Search other logs for matching timestamps.
|
||||||
|
3. Ignore earlier warnings if the job continued past them.
|
||||||
|
|
||||||
|
## Common Signal
|
||||||
|
|
||||||
|
High-signal failures:
|
||||||
|
- `ReadTimeout`
|
||||||
|
- `Connection refused`
|
||||||
|
- `CUDA out of memory`
|
||||||
|
- `NCCL timeout`
|
||||||
|
- `Model not found`
|
||||||
|
- benchmark exit code failures
|
||||||
|
|
||||||
|
Low-signal noise:
|
||||||
|
- dependency resolver warnings
|
||||||
|
- cleanup warnings during teardown
|
||||||
|
- keep-alive failures after the main crash
|
||||||
|
- import warnings unrelated to the active model
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
Write markdown with this structure:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Job Analysis: {job_id}
|
||||||
|
|
||||||
|
### Root Cause
|
||||||
|
...
|
||||||
|
|
||||||
|
### Evidence
|
||||||
|
- `file:line or file`
|
||||||
|
- timestamp
|
||||||
|
- relevant error text
|
||||||
|
|
||||||
|
### Timeline
|
||||||
|
- key event -> timestamp
|
||||||
|
|
||||||
|
### Noise
|
||||||
|
- warnings that were not causal
|
||||||
|
|
||||||
|
### Recommended Fix
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep the report concrete. Avoid generic summaries. If you are unsure, say so
|
||||||
|
and explain what evidence is missing.
|
||||||
Reference in New Issue
Block a user