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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ First public release. Bodhiorchard™ ships as an open-source, local-first AI de
- **Async job pattern** — backend returns `202` + job ID; frontend tracks via `useJobSocket` over `/ws/jobs/{job_id}`.
- **Event bus fan-out** — `event_bus.publish(...)` reaches in-process subscribers (dashboard `/ws`) and external transports (Colyseus, future Slack/metrics sinks) via a single `register_transport()` hook.
- **Bug auto-linking** — pgvector cosine search at 0.40 threshold links new bug reports to the BUDs that introduced them.
- **Contributor-XP economy** — closing a BUD awards XP and triggers a repo re-scan via the single `on_bud_closed()` entry point.
- **Contributor-XP economy** — closing a BUD awards XP and SP, computes learning metrics, and spawns the post-close Learning Agent via the single `on_bud_closed()` entry point.
- **Apache 2.0 license + NOTICE** — explicit IP-independence statement; no AGPL remnants.
- **DCO sign-off workflow** — every commit requires `Signed-off-by:` via `git commit -s`.

Expand Down
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,9 @@ bud → design → development → testing → uat → prod → closed (discar

- Markdown doc with spec / tech spec / test plan sections, numbered per org (`BUD-001`, …).
- Embeddings generated at creation time; bug-linker uses pgvector cosine distance, threshold 0.40.
- `on_bud_closed()` in `services/bud_closure.py` is the single entry point for contributor-XP + repo-scan side effects — called from both manual PATCH and auto-close.
- `on_bud_closed()` in `services/bud_closure.py` is the single entry point for contributor-XP, BUD-shipped SP, learning metrics, and the post-close Learning Agent — called from both manual PATCH and auto-close.
- Repo scans are NOT triggered on BUD close. They are owned by the PR-merge GitHub webhook (`api/v1/github_webhook.py` → `services/scan/pr_merge_update.py`), which gates on the repo's `main_branch`.
- Linked-feature `in_progress → done` transitions run in `api/v1/bud.py` via `services/feature_lifecycle.transition_feature_for_bud` on every status change, independent of `on_bud_closed`.
- Release detection has two paths: fast (`bud_id` on PR) and SHA-walk (release PRs without `bud_id`).

### Shared code
Expand Down Expand Up @@ -147,7 +149,7 @@ The stored `claude_auth_mode` on the org decides which path agent runs take.
## BUD Lifecycle Completeness

- BUDs get embeddings at creation time (for bug linker vector search)
- `on_bud_closed()` in `bud_closure.py` handles: contributor XP + repo scan (called from both manual PATCH and auto-close)
- `on_bud_closed()` in `bud_closure.py` handles: contributor XP, BUD-shipped SP, BUD learning metrics, and the post-close Learning Agent (called from both manual PATCH and auto-close). Repo scans live in the PR-merge webhook, not here.
- Release detection: fast path (bud_id on PR) vs SHA-walk path (release PRs without bud_id)
- Bug auto-linking: `bug_linker.py` uses pgvector cosine distance with 0.40 threshold

Expand Down
2 changes: 1 addition & 1 deletion backend/app/api/v1/bud.py
Original file line number Diff line number Diff line change
Expand Up @@ -983,7 +983,7 @@ async def update_bud(
except Exception:
logger.warning("xp_award_failed_bud_completion", exc_info=True)

# Post-closure side-effects: award contributor XP + trigger scan
# Post-closure side-effects: XP, SP, learning metrics, Learning Agent
if _completed:
try:
from app.services.bud_closure import on_bud_closed
Expand Down
124 changes: 25 additions & 99 deletions backend/app/services/bud_closure.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,30 @@

Called from both the manual PATCH handler (bud.py) and the automatic
closure path (_maybe_auto_close_bud in release_detection.py). Centralizes
two post-closure actions:
the post-closure rewards and learning hooks:

1. **Award XP to all contributors** — every user who committed code or
authored a PR for this BUD receives contributor XP. The assignee's
50 XP award is handled upstream (bud.py) and is NOT duplicated here.
2. **Trigger a background repo scan** — impacted repos are re-scanned so
features and skills are updated to reflect the shipped work.

Both actions are fire-and-forget: failures are logged but never block
the caller. XP awards are idempotent via ``source_ref`` dedup.
2. **Award role-based SP to the assignee** for shipping a BUD to prod.
3. **Compute BUD learning metrics** (only on CLOSED transitions).
4. **Spawn the post-close Learning Agent** when the BUD opted in via
``auto_generate_phases.closed``.

Repo scans are NOT triggered from here — they are owned by the PR-merge
GitHub webhook (``api/v1/github_webhook.py`` → ``services/scan/pr_merge_update.py``)
which gates on the repo's ``main_branch``. Closing a BUD on its own does
not advance any tracked SHA, so firing a scan from this path was both
unnecessary work and a source of false-positive feature deactivations.

Linked-features ``in_progress → done`` transition runs upstream in
``bud.py`` via ``feature_lifecycle.transition_feature_for_bud`` on every
status change, independent of this module.

Failures are logged but never block the caller. XP and SP awards are
idempotent via ``source_ref`` dedup.
"""

import asyncio
import uuid

import structlog
Expand All @@ -43,7 +54,6 @@
should_auto_generate_phase,
)
from app.services.bud_metrics import compute_and_persist as compute_bud_metrics
from app.services.scan.runner import ScanAlreadyActiveError, start_scan

logger = structlog.get_logger(__name__)

Expand All @@ -57,20 +67,18 @@ async def on_bud_closed(
) -> None:
"""Run post-closure side-effects for a BUD.

Safe to call multiple times — XP awards are deduped by ``source_ref``,
the scan is a fresh incremental run, and ``compute_bud_metrics`` is
a no-op when the FeatureLearning row already carries a ``metrics``
envelope.
Safe to call multiple times — XP and SP awards are deduped by
``source_ref``, and ``compute_bud_metrics`` is a no-op when the
FeatureLearning row already carries a ``metrics`` envelope.

Note that this hook fires on PROD *and* CLOSED transitions today
(see ``bud.py``'s ``_completed`` gate). XP/SP/scan are correct to
fire at PROD; learning-metrics work only fires at CLOSED so the
full lifecycle is captured (PROD→CLOSED happens later via
auto-close).
(see ``bud.py``'s ``_completed`` gate). XP and SP are correct to
fire at PROD; learning-metrics and the post-close Learning Agent
only fire at CLOSED so the full lifecycle is captured (PROD→CLOSED
happens later via auto-close).
"""
await _award_contributor_xp(db, org_id, bud)
await _award_bud_shipped_sp(db, org_id, bud)
_trigger_impacted_repo_scan(org_id, bud)

if bud.status == BUDStatus.CLOSED:
try:
Expand Down Expand Up @@ -189,88 +197,6 @@ async def _award_contributor_xp(
)


def _trigger_impacted_repo_scan(
org_id: uuid.UUID,
bud: BUDDocument,
) -> None:
"""Trigger a background scan for the BUD's impacted repos.

Uses its own DB session so the caller returns immediately. Runs an
incremental scan (not full rescan) to update features and skills
for the repos that shipped new code.
"""
impacted = bud.impacted_repos
if not isinstance(impacted, list) or not impacted:
return

repo_ids: list[str] = [
str(rid) for r in impacted if isinstance(r, dict) and (rid := r.get("repo_id"))
]
if not repo_ids:
return

task = asyncio.create_task(
_bg_scan(org_id, repo_ids, bud.bud_number),
name=f"bg_scan_bud_{bud.bud_number}",
)
# _bg_scan wraps its body in try/except, so success / known failures
# are already logged. The callback exists only to retrieve any
# exception that escapes _bg_scan itself (otherwise asyncio prints a
# warning about an unretrieved exception at GC time).
task.add_done_callback(_log_bg_scan_exception)


def _log_bg_scan_exception(task: asyncio.Task[None]) -> None:
"""Drain the task's exception (if any) and log it.

Calling ``task.exception()`` marks the exception as retrieved,
which prevents the asyncio "Task exception was never retrieved"
warning at GC time.
"""
if task.cancelled():
return
exc = task.exception()
if exc is not None:
logger.warning("bud_closure_bg_scan_unhandled", exc_info=exc)


async def _bg_scan(
org_id: uuid.UUID,
repo_ids: list[str],
bud_number: int,
) -> None:
"""Background task: kick off a scan for the impacted repos."""
try:
repo_uuids: list[uuid.UUID] = []
for rid in repo_ids:
try:
repo_uuids.append(uuid.UUID(rid))
except ValueError:
logger.warning("bud_closure_invalid_repo_id", bud_number=bud_number, repo_id=rid)
if not repo_uuids:
return

scan_id = await start_scan(org_id=org_id, repo_ids=repo_uuids)
logger.info(
"bud_closure_scan_started",
bud_number=bud_number,
repos_scanned=len(repo_uuids),
scan_id=str(scan_id),
)
except ScanAlreadyActiveError as exc:
logger.info(
"bud_closure_scan_skipped_already_active",
bud_number=bud_number,
active_scan_id=str(exc.scan_id),
)
except Exception:
logger.warning(
"bud_closure_scan_failed",
bud_number=bud_number,
exc_info=True,
)


async def _award_bud_shipped_sp(
db: AsyncSession,
org_id: uuid.UUID,
Expand Down
2 changes: 1 addition & 1 deletion backend/app/services/bud_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"""Compute and persist per-BUD learning metrics on close.

Single entry point ``compute_and_persist(db, org_id, bud)``, called
from ``on_bud_closed()`` after the existing XP/SP/scan side-effects.
from ``on_bud_closed()`` after the existing XP/SP side-effects.
Produces the structured envelope written to
``feature_learnings.metrics`` (versioned dict with ``phase_metrics``,
``contributors``, ``parallelism_score``, ``original_estimated_days``)
Expand Down
Loading