mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 05:43:14 +00:00
* fix: reset stale sandbox creation sentinel Co-authored-by: Johannes du Plessis <51395795+johannes117@users.noreply.github.com> * fix: treat SANDBOX_CREATING as a timestamped cross-process lock Only reset the sentinel when proven stale (older than the creation timeout); otherwise wait for the worker that holds the lock so a concurrent run does not create a duplicate sandbox. * feat(analyzer): outcomes dataset + bootstrap/continual split via skills Rename the review_style_analyzer graph to `analyzer` and split it into two modes, plus capture reviewer finding outcomes for continual learning. - Outcomes dataset: upsert resolved-by-commit (positive), dismissed (false positive), and GitHub/Slack thumbs findings into a single LangSmith dataset (openswe-reviewer-outcomes), keyed deterministically per finding+source. Emit points wired into update_finding, resolve_finding_thread, and the GitHub/Slack reaction handlers. - Two playbooks delivered as deepagents skills (bootstrap-repo-analysis, continual-learning), served as virtual files via a CompositeBackend /skills/ route + StateBackend (seeded into the run files channel at invoke time, never written to the sandbox). Mode is set by the launcher; continual runs fall back to the GitHub App installation token. - Split launcher into start_bootstrap_analysis + start_continual_run; register a per-repo nightly continual-learning cron when bootstrap completes. - New read_finding_outcomes tool feeds confirmed/dismissed findings back to the continual playbook. Tests for outcome label mapping, skills helper, and cron idempotency. * fix(analyzer): anchor continual cron runs to a real thread_id The nightly continual-learning cron is threadless, and get_analyzer early-returns an empty agent when configurable.thread_id is missing — so every cron-launched run no-op'd before reading outcomes or saving a refined prompt. Include the repo's deterministic analyzer thread_id in the continual run configurable so the run executes; the threadless run carries no message history, so nightly runs don't accumulate context. * refactor(analyzer): move cron lifecycle calls out of the review-styles store Drop the inline `analyzer_cron` imports from review_styles.py (added only to dodge a circular import) by relocating the cron-trigger calls to the layer above the store: registration to the save_review_style tool (after a prompt is saved) and removal to the dashboard delete route. review_styles.py is now a pure store again with top-level imports only. * refactor: hoist reviewer_outcomes imports to module level Move the two inline emit_finding_status_outcome imports introduced in this PR (update_finding, resolve_finding_thread) to top-level imports. reviewer_outcomes only depends on langsmith, so there is no circular import to avoid. --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
246 lines
8.7 KiB
Python
246 lines
8.7 KiB
Python
"""Kick off and sync per-repo review style analysis runs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from typing import Any
|
|
|
|
from langgraph_sdk import get_client
|
|
|
|
from ..review_style_collector import (
|
|
collect_review_samples,
|
|
format_samples_for_analyzer,
|
|
generate_review_style_thread_id,
|
|
)
|
|
from ..utils.analyzer_skills import build_skill_files
|
|
from .review_styles import (
|
|
get_review_style,
|
|
has_saved_prompt,
|
|
mark_analysis_failed,
|
|
mark_analysis_running,
|
|
reconcile_running_status,
|
|
update_review_style,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_ASSISTANT_ID = "analyzer"
|
|
|
|
|
|
def _client():
|
|
"""LangGraph SDK client for the current deployment (same resolution as webapp)."""
|
|
url = os.environ.get("LANGGRAPH_URL") or os.environ.get("LANGGRAPH_URL_PROD")
|
|
if url:
|
|
return get_client(url=url)
|
|
return get_client()
|
|
|
|
|
|
def build_continual_run_input(full_name: str) -> dict[str, Any]:
|
|
"""Run input for a continual-learning analyzer run (shared with the cron)."""
|
|
return {
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": (
|
|
f"Refine the review-style prompt for `{full_name}` using this "
|
|
"reviewer's recorded finding outcomes. Follow the continual-learning "
|
|
"skill, then save the refined prompt."
|
|
),
|
|
}
|
|
],
|
|
"files": build_skill_files(),
|
|
}
|
|
|
|
|
|
def build_continual_run_configurable(full_name: str) -> dict[str, Any]:
|
|
"""Configurable for a continual-learning analyzer run (shared with the cron).
|
|
|
|
Includes an explicit ``thread_id`` so the run is anchored to the repo's
|
|
deterministic analyzer thread. The nightly cron is threadless, so without
|
|
this ``get_analyzer`` would early-return an empty agent (no thread_id) and
|
|
the run would no-op. Reusing the deterministic id keys the sandbox + thread
|
|
metadata to the repo; the threadless run carries no message history, so it
|
|
does not accumulate across nights.
|
|
"""
|
|
owner, repo = full_name.split("/", 1)
|
|
return {
|
|
"thread_id": generate_review_style_thread_id(owner, repo),
|
|
"review_style_full_name": full_name,
|
|
"analyzer_mode": "continual",
|
|
}
|
|
|
|
|
|
async def start_bootstrap_analysis(
|
|
full_name: str,
|
|
*,
|
|
github_token: str,
|
|
created_by: str,
|
|
) -> dict[str, Any]:
|
|
"""Collect samples, persist metadata, and start a bootstrap analyzer run."""
|
|
owner, repo = full_name.split("/", 1)
|
|
try:
|
|
samples = await collect_review_samples(github_token, owner, repo)
|
|
except Exception:
|
|
logger.exception("Failed to collect review samples for %s", full_name)
|
|
await mark_analysis_failed(full_name, "sample collection failed")
|
|
record = await get_review_style(full_name)
|
|
return record or {
|
|
"full_name": full_name,
|
|
"status": "failed",
|
|
"error": "Sample collection failed. Please retry later.",
|
|
}
|
|
|
|
samples_text = format_samples_for_analyzer(samples)
|
|
thread_id = generate_review_style_thread_id(owner, repo)
|
|
|
|
client = _client()
|
|
configurable: dict[str, Any] = {
|
|
"thread_id": thread_id,
|
|
"review_style_full_name": full_name,
|
|
"review_style_github_token": github_token,
|
|
"review_style_samples_text": samples_text,
|
|
"review_style_top_reviewers": samples.top_reviewers,
|
|
"review_style_prs_sampled": samples.prs_scanned,
|
|
"review_style_reviews_sampled": samples.reviews_scanned,
|
|
"analyzer_mode": "bootstrap",
|
|
}
|
|
if not samples.samples:
|
|
logger.info(
|
|
"No pre-collected samples for %s (%s merged PRs scanned); analyzer will fetch via API",
|
|
full_name,
|
|
samples.prs_scanned,
|
|
)
|
|
|
|
await mark_analysis_running(
|
|
full_name,
|
|
thread_id=thread_id,
|
|
run_id=None,
|
|
top_reviewers=samples.top_reviewers,
|
|
prs_sampled=samples.prs_scanned,
|
|
reviews_sampled=samples.reviews_scanned,
|
|
)
|
|
|
|
try:
|
|
run = await client.runs.create(
|
|
thread_id,
|
|
_ASSISTANT_ID,
|
|
input={
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": (
|
|
f"Analyze review style for `{full_name}`. Follow the "
|
|
"bootstrap-repo-analysis skill: browse merged PR review feedback "
|
|
"with `GH_TOKEN=dummy gh` until you have enough human examples, "
|
|
"then save the repository-specific prompt."
|
|
),
|
|
}
|
|
],
|
|
"files": build_skill_files(),
|
|
},
|
|
config={"configurable": configurable},
|
|
if_not_exists="create",
|
|
)
|
|
run_id = run.get("run_id") if isinstance(run, dict) else getattr(run, "run_id", None)
|
|
record = await update_review_style(
|
|
full_name,
|
|
{"analysis_run_id": run_id, "created_by": created_by},
|
|
)
|
|
return record
|
|
except Exception:
|
|
logger.exception("Failed to start review style analyzer for %s", full_name)
|
|
await mark_analysis_failed(full_name, "run start failed")
|
|
record = await get_review_style(full_name)
|
|
return record or {
|
|
"full_name": full_name,
|
|
"status": "failed",
|
|
"error": "Failed to start analysis. Please retry later.",
|
|
}
|
|
|
|
|
|
async def start_continual_run(
|
|
full_name: str,
|
|
*,
|
|
created_by: str = "manual",
|
|
) -> dict[str, Any]:
|
|
"""Start an immediate continual-learning run (outcome-driven refinement)."""
|
|
configurable = build_continual_run_configurable(full_name)
|
|
thread_id = configurable["thread_id"]
|
|
try:
|
|
run = await _client().runs.create(
|
|
thread_id,
|
|
_ASSISTANT_ID,
|
|
input=build_continual_run_input(full_name),
|
|
config={"configurable": configurable},
|
|
if_not_exists="create",
|
|
)
|
|
run_id = run.get("run_id") if isinstance(run, dict) else getattr(run, "run_id", None)
|
|
return await update_review_style(
|
|
full_name,
|
|
{"analysis_run_id": run_id, "created_by": created_by},
|
|
)
|
|
except Exception:
|
|
logger.exception("Failed to start continual analyzer run for %s", full_name)
|
|
record = await get_review_style(full_name)
|
|
return record or {"full_name": full_name, "status": "failed", "error": "run start failed"}
|
|
|
|
|
|
async def sync_review_style_run_status(full_name: str) -> dict[str, Any]:
|
|
"""Refresh store status from the latest analyzer run when still running."""
|
|
record = await get_review_style(full_name)
|
|
if not record or record.get("status") != "running":
|
|
return record or {}
|
|
|
|
thread_id = record.get("analysis_thread_id")
|
|
run_id = record.get("analysis_run_id")
|
|
if not isinstance(thread_id, str) or not thread_id:
|
|
return record
|
|
|
|
client = _client()
|
|
run_status: str | None = None
|
|
run_missing = False
|
|
try:
|
|
if isinstance(run_id, str) and run_id:
|
|
run = await client.runs.get(thread_id, run_id)
|
|
else:
|
|
runs = await client.runs.list(thread_id, limit=1)
|
|
items = runs if isinstance(runs, list) else (runs.get("runs") or [])
|
|
run = items[0] if items else None
|
|
if not run:
|
|
run_missing = True
|
|
else:
|
|
raw = run.get("status") if isinstance(run, dict) else getattr(run, "status", None)
|
|
run_status = raw.lower() if isinstance(raw, str) else None
|
|
except Exception:
|
|
logger.debug("Could not sync run status for %s", full_name, exc_info=True)
|
|
return record
|
|
|
|
return await reconcile_running_status(
|
|
full_name, record, run_status=run_status, run_missing=run_missing
|
|
)
|
|
|
|
|
|
async def cancel_review_style_analysis(full_name: str) -> dict[str, Any]:
|
|
"""Stop an in-flight analyzer run and clear stale ``running`` status."""
|
|
record = await get_review_style(full_name)
|
|
if not record:
|
|
return {}
|
|
|
|
if record.get("status") != "running":
|
|
return record
|
|
|
|
thread_id = record.get("analysis_thread_id")
|
|
run_id = record.get("analysis_run_id")
|
|
if isinstance(thread_id, str) and isinstance(run_id, str) and thread_id and run_id:
|
|
try:
|
|
await _client().runs.cancel(thread_id, run_id, wait=False)
|
|
except Exception:
|
|
logger.debug("Could not cancel review style run for %s", full_name, exc_info=True)
|
|
|
|
if has_saved_prompt(record):
|
|
return await update_review_style(full_name, {"status": "completed", "error": None})
|
|
return await update_review_style(
|
|
full_name,
|
|
{"status": "idle", "error": None, "analysis_run_id": None},
|
|
)
|