diff --git a/CHANGELOG.md b/CHANGELOG.md index 24830eeb..0544e75c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/CLAUDE.md b/CLAUDE.md index 751dee37..e919636a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 diff --git a/backend/app/api/v1/bud.py b/backend/app/api/v1/bud.py index 52f9055f..65eaf388 100644 --- a/backend/app/api/v1/bud.py +++ b/backend/app/api/v1/bud.py @@ -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 diff --git a/backend/app/services/bud_closure.py b/backend/app/services/bud_closure.py index 80a526c0..b9941d8a 100644 --- a/backend/app/services/bud_closure.py +++ b/backend/app/services/bud_closure.py @@ -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 @@ -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__) @@ -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: @@ -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, diff --git a/backend/app/services/bud_metrics.py b/backend/app/services/bud_metrics.py index 66c9b390..3734c053 100644 --- a/backend/app/services/bud_metrics.py +++ b/backend/app/services/bud_metrics.py @@ -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``)