Skip to content

Commit 9c23572

Browse files
committed
fix(bud-closure): stop firing repo scans on BUD close
Closing a BUD via PATCH /api/v1/buds/{id} (status -> closed/prod) was dispatching an incremental repo scan through bud_closure._trigger_impacted_repo_scan against whichever HEAD the repo happened to point at -- no main-branch / merge / deployment / HEAD-advance check. On 2026-06-02 this caused BUD #8's close to deactivate 10 cross-cutting features (Permissions & Roles, Auth Context, Sandbox Refunds, etc.) at a SHA the BUD never even shipped to main. Repo scans now live exclusively in the PR-merge GitHub webhook (api/v1/github_webhook.py -> services/scan/pr_merge_update.py), which already gates on main_branch and only fires when a real merge advances the tracked HEAD. on_bud_closed keeps every other side effect: contributor XP, BUD-shipped SP, BUD learning metrics (CLOSED only), and the post-close Learning Agent. The linked-features in_progress -> done transition continues to run upstream in bud.py via feature_lifecycle.transition_feature_for_bud, unchanged. CLAUDE.md and CHANGELOG.md updated so future readers find the scan trigger in the right place. Verified locally: with the patch loaded, PATCH BUD 246 prod -> closed produced no new row in scans and no features.is_active flips on the impacted repo (taskflow-web stayed at 5 active / 0 inactive). Signed-off-by: Arun Rajkumar <mickyarunr@gmail.com>
1 parent f4f5f91 commit 9c23572

5 files changed

Lines changed: 32 additions & 104 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ First public release. Bodhiorchard™ ships as an open-source, local-first AI de
2525
- **Async job pattern** — backend returns `202` + job ID; frontend tracks via `useJobSocket` over `/ws/jobs/{job_id}`.
2626
- **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.
2727
- **Bug auto-linking** — pgvector cosine search at 0.40 threshold links new bug reports to the BUDs that introduced them.
28-
- **Contributor-XP economy** — closing a BUD awards XP and triggers a repo re-scan via the single `on_bud_closed()` entry point.
28+
- **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.
2929
- **Apache 2.0 license + NOTICE** — explicit IP-independence statement; no AGPL remnants.
3030
- **DCO sign-off workflow** — every commit requires `Signed-off-by:` via `git commit -s`.
3131

CLAUDE.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,9 @@ bud → design → development → testing → uat → prod → closed (discar
113113

114114
- Markdown doc with spec / tech spec / test plan sections, numbered per org (`BUD-001`, …).
115115
- Embeddings generated at creation time; bug-linker uses pgvector cosine distance, threshold 0.40.
116-
- `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.
116+
- `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.
117+
- 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`.
118+
- 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`.
117119
- Release detection has two paths: fast (`bud_id` on PR) and SHA-walk (release PRs without `bud_id`).
118120

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

149151
- BUDs get embeddings at creation time (for bug linker vector search)
150-
- `on_bud_closed()` in `bud_closure.py` handles: contributor XP + repo scan (called from both manual PATCH and auto-close)
152+
- `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.
151153
- Release detection: fast path (bud_id on PR) vs SHA-walk path (release PRs without bud_id)
152154
- Bug auto-linking: `bug_linker.py` uses pgvector cosine distance with 0.40 threshold
153155

backend/app/api/v1/bud.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -983,7 +983,7 @@ async def update_bud(
983983
except Exception:
984984
logger.warning("xp_award_failed_bud_completion", exc_info=True)
985985

986-
# Post-closure side-effects: award contributor XP + trigger scan
986+
# Post-closure side-effects: XP, SP, learning metrics, Learning Agent
987987
if _completed:
988988
try:
989989
from app.services.bud_closure import on_bud_closed

backend/app/services/bud_closure.py

Lines changed: 25 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,30 @@
1616
1717
Called from both the manual PATCH handler (bud.py) and the automatic
1818
closure path (_maybe_auto_close_bud in release_detection.py). Centralizes
19-
two post-closure actions:
19+
the post-closure rewards and learning hooks:
2020
2121
1. **Award XP to all contributors** — every user who committed code or
2222
authored a PR for this BUD receives contributor XP. The assignee's
2323
50 XP award is handled upstream (bud.py) and is NOT duplicated here.
24-
2. **Trigger a background repo scan** — impacted repos are re-scanned so
25-
features and skills are updated to reflect the shipped work.
26-
27-
Both actions are fire-and-forget: failures are logged but never block
28-
the caller. XP awards are idempotent via ``source_ref`` dedup.
24+
2. **Award role-based SP to the assignee** for shipping a BUD to prod.
25+
3. **Compute BUD learning metrics** (only on CLOSED transitions).
26+
4. **Spawn the post-close Learning Agent** when the BUD opted in via
27+
``auto_generate_phases.closed``.
28+
29+
Repo scans are NOT triggered from here — they are owned by the PR-merge
30+
GitHub webhook (``api/v1/github_webhook.py`` → ``services/scan/pr_merge_update.py``)
31+
which gates on the repo's ``main_branch``. Closing a BUD on its own does
32+
not advance any tracked SHA, so firing a scan from this path was both
33+
unnecessary work and a source of false-positive feature deactivations.
34+
35+
Linked-features ``in_progress → done`` transition runs upstream in
36+
``bud.py`` via ``feature_lifecycle.transition_feature_for_bud`` on every
37+
status change, independent of this module.
38+
39+
Failures are logged but never block the caller. XP and SP awards are
40+
idempotent via ``source_ref`` dedup.
2941
"""
3042

31-
import asyncio
3243
import uuid
3344

3445
import structlog
@@ -43,7 +54,6 @@
4354
should_auto_generate_phase,
4455
)
4556
from app.services.bud_metrics import compute_and_persist as compute_bud_metrics
46-
from app.services.scan.runner import ScanAlreadyActiveError, start_scan
4757

4858
logger = structlog.get_logger(__name__)
4959

@@ -57,20 +67,18 @@ async def on_bud_closed(
5767
) -> None:
5868
"""Run post-closure side-effects for a BUD.
5969
60-
Safe to call multiple times — XP awards are deduped by ``source_ref``,
61-
the scan is a fresh incremental run, and ``compute_bud_metrics`` is
62-
a no-op when the FeatureLearning row already carries a ``metrics``
63-
envelope.
70+
Safe to call multiple times — XP and SP awards are deduped by
71+
``source_ref``, and ``compute_bud_metrics`` is a no-op when the
72+
FeatureLearning row already carries a ``metrics`` envelope.
6473
6574
Note that this hook fires on PROD *and* CLOSED transitions today
66-
(see ``bud.py``'s ``_completed`` gate). XP/SP/scan are correct to
67-
fire at PROD; learning-metrics work only fires at CLOSED so the
68-
full lifecycle is captured (PROD→CLOSED happens later via
69-
auto-close).
75+
(see ``bud.py``'s ``_completed`` gate). XP and SP are correct to
76+
fire at PROD; learning-metrics and the post-close Learning Agent
77+
only fire at CLOSED so the full lifecycle is captured (PROD→CLOSED
78+
happens later via auto-close).
7079
"""
7180
await _award_contributor_xp(db, org_id, bud)
7281
await _award_bud_shipped_sp(db, org_id, bud)
73-
_trigger_impacted_repo_scan(org_id, bud)
7482

7583
if bud.status == BUDStatus.CLOSED:
7684
try:
@@ -189,88 +197,6 @@ async def _award_contributor_xp(
189197
)
190198

191199

192-
def _trigger_impacted_repo_scan(
193-
org_id: uuid.UUID,
194-
bud: BUDDocument,
195-
) -> None:
196-
"""Trigger a background scan for the BUD's impacted repos.
197-
198-
Uses its own DB session so the caller returns immediately. Runs an
199-
incremental scan (not full rescan) to update features and skills
200-
for the repos that shipped new code.
201-
"""
202-
impacted = bud.impacted_repos
203-
if not isinstance(impacted, list) or not impacted:
204-
return
205-
206-
repo_ids: list[str] = [
207-
str(rid) for r in impacted if isinstance(r, dict) and (rid := r.get("repo_id"))
208-
]
209-
if not repo_ids:
210-
return
211-
212-
task = asyncio.create_task(
213-
_bg_scan(org_id, repo_ids, bud.bud_number),
214-
name=f"bg_scan_bud_{bud.bud_number}",
215-
)
216-
# _bg_scan wraps its body in try/except, so success / known failures
217-
# are already logged. The callback exists only to retrieve any
218-
# exception that escapes _bg_scan itself (otherwise asyncio prints a
219-
# warning about an unretrieved exception at GC time).
220-
task.add_done_callback(_log_bg_scan_exception)
221-
222-
223-
def _log_bg_scan_exception(task: asyncio.Task[None]) -> None:
224-
"""Drain the task's exception (if any) and log it.
225-
226-
Calling ``task.exception()`` marks the exception as retrieved,
227-
which prevents the asyncio "Task exception was never retrieved"
228-
warning at GC time.
229-
"""
230-
if task.cancelled():
231-
return
232-
exc = task.exception()
233-
if exc is not None:
234-
logger.warning("bud_closure_bg_scan_unhandled", exc_info=exc)
235-
236-
237-
async def _bg_scan(
238-
org_id: uuid.UUID,
239-
repo_ids: list[str],
240-
bud_number: int,
241-
) -> None:
242-
"""Background task: kick off a scan for the impacted repos."""
243-
try:
244-
repo_uuids: list[uuid.UUID] = []
245-
for rid in repo_ids:
246-
try:
247-
repo_uuids.append(uuid.UUID(rid))
248-
except ValueError:
249-
logger.warning("bud_closure_invalid_repo_id", bud_number=bud_number, repo_id=rid)
250-
if not repo_uuids:
251-
return
252-
253-
scan_id = await start_scan(org_id=org_id, repo_ids=repo_uuids)
254-
logger.info(
255-
"bud_closure_scan_started",
256-
bud_number=bud_number,
257-
repos_scanned=len(repo_uuids),
258-
scan_id=str(scan_id),
259-
)
260-
except ScanAlreadyActiveError as exc:
261-
logger.info(
262-
"bud_closure_scan_skipped_already_active",
263-
bud_number=bud_number,
264-
active_scan_id=str(exc.scan_id),
265-
)
266-
except Exception:
267-
logger.warning(
268-
"bud_closure_scan_failed",
269-
bud_number=bud_number,
270-
exc_info=True,
271-
)
272-
273-
274200
async def _award_bud_shipped_sp(
275201
db: AsyncSession,
276202
org_id: uuid.UUID,

backend/app/services/bud_metrics.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"""Compute and persist per-BUD learning metrics on close.
1616
1717
Single entry point ``compute_and_persist(db, org_id, bud)``, called
18-
from ``on_bud_closed()`` after the existing XP/SP/scan side-effects.
18+
from ``on_bud_closed()`` after the existing XP/SP side-effects.
1919
Produces the structured envelope written to
2020
``feature_learnings.metrics`` (versioned dict with ``phase_metrics``,
2121
``contributors``, ``parallelism_score``, ``original_estimated_days``)

0 commit comments

Comments
 (0)