Skip to content

fix: don't fail extension activation when a workspace folder has no dbt project#1939

Open
ralphstodomingo wants to merge 3 commits intomasterfrom
fix/multi-root-empty-folder-no-projects-found
Open

fix: don't fail extension activation when a workspace folder has no dbt project#1939
ralphstodomingo wants to merge 3 commits intomasterfrom
fix/multi-root-empty-folder-no-projects-found

Conversation

@ralphstodomingo
Copy link
Copy Markdown
Contributor

@ralphstodomingo ralphstodomingo commented May 7, 2026

Summary

Production telemetry surfaced cluster extensionActivationError (83 unique machines / 104 events / 24h, both 0.60.7 and 0.61.0) with the stack pinned at:

Error: no projects found. retrying
    at DBTWorkspaceFolder.retryWithBackoff
    at async DBTWorkspaceFolder.discoverProjects
    at async DBTProjectContainer.registerWorkspaceFolder
    at async Promise.all (index 0)
    at async DBTProjectContainer.initializeDBTProjects
    at async DBTPowerUserExtension.activate

Distribution: ~95% darwin + linux, ~95% core integration mode, every top-version contributing — consistent with the multi-root workspace pattern common on dev machines, and inconsistent with a regression.

Root cause

VS Code's workspaceContains:**/dbt_project.yml activation matches when any workspace folder contains a dbt_project.yml. The extension then runs discoverProjects on every workspace folder via Promise.all(folders.map(registerWorkspaceFolder)). For a folder that genuinely has no dbt project — typical for multi-root workspaces where a sibling folder triggered activation — findFiles returns [] on every attempt, retryWithBackoff exhausts after ~10s (5 attempts with 1+2+3+4 backoff), and the thrown "no projects found. retrying" propagates up to the activate-level catch.

The visible damage isn't just the telemetry noise — the throw also short-circuits the rest of activate(), so statusBars.initialize() and the workspace-folder change listener never run. Users with a mixed-folder workspace get a working dbt project for the folder that matched, plus silently-degraded status-bar UX for the rest of the session.

Fix

In retryWithBackoff, treat a consistently-empty result after the retry budget as legitimately empty and return [] instead of throwing. The retry schedule and the empty/non-empty differentiation are unchanged — only the terminal disposition. Real errors from fn() (permission failures, malformed pattern, indexer crashes) still throw after retries, preserving the existing escalation for non-empty failure modes.

The constructor's createConfigWatcher (line 60) keeps watching for dbt_project.yml creation in this folder, so a project added later is still picked up automatically.

This also restructures the loop so empty results are handled by normal control flow rather than via a thrown-and-caught sentinel error, which is cleaner and removes the previously-unreachable "no projects found after maximum retries" throw at the bottom of the function.

Pre-fix → post-fix evidence

A new test file (src/test/suite/discoverProjectsRepro.test.ts) instantiates the real DBTWorkspaceFolder and stubs only workspace.findFiles. Same harness shape both pre- and post-fix.

Pre-fix (against this branch's HEAD~1, i.e. origin/master) — the harness asserted on the bug shape and passed, confirming the production stack reproduces deterministically:

✓ discoverProjects throws 'no projects found. retrying' after 5 retries (10007 ms)
✓ retry exhaustion takes ~10s (matches production telemetry cluster pattern) (10005 ms)
✓ multi-root: empty folder rejects in parallel with happy folder (Promise.all index 0 pattern) (10013 ms)

Post-fix (this branch) — the assertions flip to verify the new behaviour, plus a new fourth case guards against accidentally swallowing genuine errors:

✓ returns no projects (does not throw) when findFiles is consistently empty (10024 ms)
✓ retry budget is still ~10s — confirms backoff schedule unchanged (10006 ms)
✓ multi-root: empty folder no longer fails Promise.all alongside happy folder (10007 ms)
✓ real findFiles errors still propagate after retries (preserves existing escalation) (10015 ms)

Saved at screenshots/no-projects-found-investigation/repro-output.txt (pre-fix) and screenshots/no-projects-found-investigation/after-fix-repro.txt (post-fix).

Test plan

  • New test file passes (4 cases, ~40s total — backoff drives the duration)
  • Full Jest suite — 32 of 33 suites pass; the only failure (jupyterlabServicesCompat.test.ts) is pre-existing on origin/master (missing build artifact in local env), confirmed unrelated by stashing this PR's changes and re-running it
  • Retry schedule unchanged — backoff window asserted at [9.5s, 12s) in test 2
  • Real-error escalation preserved — test 4 confirms findFiles rejecting with EACCES still throws after 5 retries

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Folders with no project files no longer cause activation failures: repeated empty search results are treated as legitimately empty after retry attempts and resolve successfully.
    • Multi-folder workspaces activate even when one or more folders lack project files; genuine errors still trigger retries and are reported via telemetry.
  • Tests

    • Added regression tests covering multi-root behavior, retry/backoff timing, suppression of telemetry for empty results, and telemetry for real errors.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 7, 2026

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

retryWithBackoff now throws a NoProjectsFound sentinel for empty-array results (including after exhausting retries); discoverProjects catches that sentinel, treats the folder as empty without sending discoverProjectsError telemetry, and new Jest tests cover empty, timing, multi-root, and real-error behaviors.

Changes

Empty-result discovery DAG

Layer / File(s) Summary
Sentinel type
src/dbt_client/dbtWorkspaceFolder.ts
Add internal NoProjectsFound extends Error to represent “no dbt projects found.”
Retry/backoff empty-result handling
src/dbt_client/dbtWorkspaceFolder.ts
retryWithBackoff throws NoProjectsFound when fn() returns [] and throws NoProjectsFound("no projects found after maximum retries") after exhausting retries for that case; other exceptions are retried and rethrown on limit exhaustion.
discoverProjects telemetry handling
src/dbt_client/dbtWorkspaceFolder.ts
discoverProjects wraps the retryWithBackoff(...) call in try/catch, treats caught NoProjectsFound as [] (suppressing discoverProjectsError telemetry), and still emits telemetry for other errors.
Regression tests
src/test/suite/discoverProjectsRepro.test.ts
Add tests: persistent empty findFiles resolves with 5 attempts and no telemetry; timing test (~10s backoff window); multi-root Promise.all with empty and happy folders resolves and is governed by the slow branch; real findFiles rejection retries 5 times and emits discoverProjectsError once.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested reviewers

  • mdesmet
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main fix: preventing extension activation failure when workspace folders lack a dbt project.
Description check ✅ Passed The description thoroughly covers the problem, root cause, solution, evidence, and test plan. It exceeds the template's basic requirements with detailed production telemetry data, stack traces, and comprehensive test assertions.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/multi-root-empty-folder-no-projects-found

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 7, 2026

Bundle Size Report

darwin-arm64: 74.2 MB
Category Size Compressed Files
Webview JS bundles 36.3 MB 12.3 MB 346
Native: altimate-core 35.1 MB 14.0 MB 1
Media assets 29.6 MB 25.8 MB 91
Native: zeromq 20.5 MB 8.2 MB 15
Webview images 15.3 MB 12.2 MB 18
Extension backend (JS) 2.7 MB 0.6 MB 1
Python packages 2.0 MB 0.5 MB 95
Native: other node_modules 1.0 MB 0.2 MB 139
Webview CSS 0.8 MB 0.1 MB 2
Webview other 0.5 MB 0.1 MB 5
Other 0.1 MB 26 KB 15
Total 143.9 MB 74.0 MB 728
linux-x64: 75.9 MB
Category Size Compressed Files
Native: altimate-core 41.8 MB 15.1 MB 1
Webview JS bundles 36.3 MB 12.3 MB 346
Media assets 29.6 MB 25.8 MB 91
Native: zeromq 21.9 MB 8.7 MB 16
Webview images 15.3 MB 12.2 MB 18
Extension backend (JS) 2.7 MB 0.6 MB 1
Python packages 2.0 MB 0.5 MB 95
Native: other node_modules 1.0 MB 0.2 MB 139
Webview CSS 0.8 MB 0.1 MB 2
Webview other 0.5 MB 0.1 MB 5
Other 0.1 MB 26 KB 15
Total 152.0 MB 75.7 MB 729
win32-x64: 76.8 MB
Category Size Compressed Files
Native: altimate-core 50.3 MB 16.2 MB 1
Webview JS bundles 36.3 MB 12.3 MB 346
Media assets 29.6 MB 25.8 MB 91
Native: zeromq 20.0 MB 8.1 MB 15
Webview images 15.3 MB 12.2 MB 18
Extension backend (JS) 2.7 MB 0.6 MB 1
Native: other node_modules 2.3 MB 0.7 MB 147
Python packages 2.0 MB 0.5 MB 95
Webview CSS 0.8 MB 0.1 MB 2
Webview other 0.5 MB 0.1 MB 5
Other 0.1 MB 26 KB 15
Total 159.8 MB 76.7 MB 736

@ralphstodomingo ralphstodomingo self-assigned this May 7, 2026
@ralphstodomingo ralphstodomingo force-pushed the fix/multi-root-empty-folder-no-projects-found branch from cc06630 to 5b01b74 Compare May 7, 2026 19:08
@ralphstodomingo ralphstodomingo requested a review from mdesmet May 7, 2026 19:15
@ralphstodomingo ralphstodomingo marked this pull request as ready for review May 7, 2026 19:15
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
src/test/suite/discoverProjectsRepro.test.ts (2)

79-155: ⚖️ Poor tradeoff

Consider fake timers to avoid ~40 s suite overhead

All four tests run the real setTimeout-based backoff (1 + 2 + 3 + 4 = 10 s per test). Only test 2 ("retry budget is still ~10s") has a semantic need for wall-clock elapsed time; the other three tests assert on call counts and resolve/reject behavior, not on timing. Using jest.useFakeTimers() for those three would make them instantaneous and eliminate the flakiness risk on loaded CI runners.

♻️ Sketch — fake timers for test 1 (call-count assertion only)
-  it("returns no projects (does not throw) when findFiles is consistently empty", async () => {
+  it("returns no projects (does not throw) when findFiles is consistently empty", async () => {
+    jest.useFakeTimers();
     const findFilesMock = stubWorkspaceFindFiles(() =>
       Promise.resolve([] as Uri[]),
     );
     const folder = makeFolder();
-    await expect(folder.discoverProjects()).resolves.toBeUndefined();
+    const promise = folder.discoverProjects();
+    // Advance through all backoff waits (1 + 2 + 3 + 4 = 10 s)
+    await jest.runAllTimersAsync();
+    await expect(promise).resolves.toBeUndefined();
     expect(findFilesMock).toHaveBeenCalledTimes(5);
-  }, 20000);
+    jest.useRealTimers();
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/test/suite/discoverProjectsRepro.test.ts` around lines 79 - 155, Replace
real timers with Jest fake timers in the tests that only assert call
counts/behavior (the first "returns no projects...", the third "multi-root:
empty folder...", and the fourth "real findFiles errors...") by calling
jest.useFakeTimers() at the start of each such it-block (or in a beforeEach
scoped to those tests), invoke folder.discoverProjects() (or Promise.all(...)
for the multi-root case), then advance timers to cover the backoff schedule
(e.g. jest.advanceTimersByTime(10000) or runAllTimers()) so the internal
setTimeout backoffs in discoverProjects run instantly, assert on
stubWorkspaceFindFiles and results, and finally restore timers with
jest.useRealTimers(); keep the second test ("retry budget is still ~10s") using
real timers unchanged. Ensure you reference the existing helpers
stubWorkspaceFindFiles, makeFolder, and the method discoverProjects when
updating the tests.

108-109: ⚡ Quick win

Timing upper-bound of 12 000 ms may be tight on throttled CI runners

The lower bound (9 500 ms) is intentional, but a 2 s ceiling can be breached when the scheduler is heavily loaded or the container is CPU-constrained. Consider raising the upper bound or documenting the acceptable CI environment.

🛡️ Suggested adjustment
-    expect(elapsed).toBeLessThan(12000);
+    expect(elapsed).toBeLessThan(15000); // 5 s headroom for throttled CI
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/test/suite/discoverProjectsRepro.test.ts` around lines 108 - 109, The
upper bound on elapsed in the test's assertions (the
expect(elapsed).toBeLessThan(12000) line in the discoverProjectsRepro test) is
too tight for throttled CI; update the expectation to a higher ceiling (e.g.,
toBeLessThan(20000) or toBeLessThanOrEqual(15000)) or replace the strict
upper-bound with a looser one that accommodates loaded runners, keeping the
intentional lower bound (expect(elapsed).toBeGreaterThanOrEqual(9500))
unchanged.
src/dbt_client/dbtWorkspaceFolder.ts (1)

88-144: 💤 Low value

LGTM — fix is correct and well-scoped.

The empty-array retry path is logically sound:

  • Backoff schedule is identical to the pre-fix error path (backoff * attempt after increment), so the timing contract is preserved.
  • The post-loop return lastEmpty as T is safe in practice since this private method is always called with retries = 5, but worth noting that lastEmpty remains undefined when retries ≤ 0, making the cast technically unsound for that edge case.
🛡️ Optional defensive fix for the type-cast edge case
-    return lastEmpty as T;
+    return (lastEmpty ?? []) as T;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/dbt_client/dbtWorkspaceFolder.ts` around lines 88 - 144, The
retryWithBackoff method can return lastEmpty as T which is undefined when
retries ≤ 0; update retryWithBackoff to defensively handle that edge-case by
checking if lastEmpty is undefined before the final return and throwing a clear
error (or returning an appropriate default) instead of casting; modify the end
of retryWithBackoff to: if (lastEmpty === undefined) throw new
Error("retryWithBackoff: no successful attempts/empty result not recorded");
otherwise return lastEmpty as T — reference function retryWithBackoff, variable
lastEmpty, and the final dbtTerminal.debug call to locate the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/dbt_client/dbtWorkspaceFolder.ts`:
- Around line 88-144: The retryWithBackoff method can return lastEmpty as T
which is undefined when retries ≤ 0; update retryWithBackoff to defensively
handle that edge-case by checking if lastEmpty is undefined before the final
return and throwing a clear error (or returning an appropriate default) instead
of casting; modify the end of retryWithBackoff to: if (lastEmpty === undefined)
throw new Error("retryWithBackoff: no successful attempts/empty result not
recorded"); otherwise return lastEmpty as T — reference function
retryWithBackoff, variable lastEmpty, and the final dbtTerminal.debug call to
locate the change.

In `@src/test/suite/discoverProjectsRepro.test.ts`:
- Around line 79-155: Replace real timers with Jest fake timers in the tests
that only assert call counts/behavior (the first "returns no projects...", the
third "multi-root: empty folder...", and the fourth "real findFiles errors...")
by calling jest.useFakeTimers() at the start of each such it-block (or in a
beforeEach scoped to those tests), invoke folder.discoverProjects() (or
Promise.all(...) for the multi-root case), then advance timers to cover the
backoff schedule (e.g. jest.advanceTimersByTime(10000) or runAllTimers()) so the
internal setTimeout backoffs in discoverProjects run instantly, assert on
stubWorkspaceFindFiles and results, and finally restore timers with
jest.useRealTimers(); keep the second test ("retry budget is still ~10s") using
real timers unchanged. Ensure you reference the existing helpers
stubWorkspaceFindFiles, makeFolder, and the method discoverProjects when
updating the tests.
- Around line 108-109: The upper bound on elapsed in the test's assertions (the
expect(elapsed).toBeLessThan(12000) line in the discoverProjectsRepro test) is
too tight for throttled CI; update the expectation to a higher ceiling (e.g.,
toBeLessThan(20000) or toBeLessThanOrEqual(15000)) or replace the strict
upper-bound with a looser one that accommodates loaded runners, keeping the
intentional lower bound (expect(elapsed).toBeGreaterThanOrEqual(9500))
unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9009ac56-455a-4dbb-b25e-a69b05315e25

📥 Commits

Reviewing files that changed from the base of the PR and between 68ef665 and 5b01b74.

📒 Files selected for processing (2)
  • src/dbt_client/dbtWorkspaceFolder.ts
  • src/test/suite/discoverProjectsRepro.test.ts

@ralphstodomingo ralphstodomingo force-pushed the fix/multi-root-empty-folder-no-projects-found branch from 5b01b74 to 778f0b0 Compare May 8, 2026 02:58
@ralphstodomingo
Copy link
Copy Markdown
Contributor Author

Reply to @coderabbitai review

Triaged the three nitpicks:

src/dbt_client/dbtWorkspaceFolder.ts:88-144defensive lastEmpty ?? []applied in latest force-push

Pushed as (lastEmpty ?? []) as T;. Tiny defensive change, removes the theoretical retries <= 0 footgun where the loop body never executes. The private callers all pass retries = 5 today so it's not a real path, but the cast was technically unsound and this costs nothing.

src/test/suite/discoverProjectsRepro.test.ts:79-155fake timers to avoid 40s overheadskipping

Three of the four cases would run instantly with fake timers, but the second test (retry budget is still ~10s) genuinely needs real-clock timing — that's its whole assertion. Mixing fake/real timers across cases in the same file is messier than uniform real timers, and 40s for an integration-shape regression test that exercises a real backoff schedule end-to-end against the actual DBTWorkspaceFolder source is a deliberate trade. The point of these tests is to prove the timing contract (10s window unchanged) and the post-loop disposition (return [] vs throw); shrinking three of them to mock-time tests would weaken that.

src/test/suite/discoverProjectsRepro.test.ts:108-109upper bound 12000ms tight on throttled CIdeferring

Reasonable concern, but the [9500, 12000) window is fitting cleanly in CI today across all three platform runners (mac / linux / win). Loosening the upper bound to 15000+ reduces the assertion's value (it stops tightly verifying "this is actually a 10-second schedule"). I'd rather wait for an actual flaky run before relaxing — happy to bump it then.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 8, 2026

Tip

For best results, initiate chat on the files or code changes.

@ralphstodomingo: Thanks for the thorough triage — all three decisions are well-reasoned.

  1. lastEmpty ?? [] (applied) — Good call; the cast is now sound regardless of how retries is parameterized in the future. No concerns.

  2. Fake timers (skipping) — Agreed. The second test's raison d'être is the real timing assertion; hollowing it out with fake timers would undermine exactly what it's trying to prove. Uniform real-clock tests across the file is the cleaner trade-off.

  3. Upper bound 12000 ms (deferring) — Reasonable stance. Widening the window preemptively dilutes the timing contract, and "wait for a real flaky run before relaxing" is the right engineering instinct here.

( ˘ᵕ˘)🐇 ✨


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

Comment thread src/dbt_client/dbtWorkspaceFolder.ts Outdated
Comment on lines +143 to +147
// Defensive `?? []` covers the theoretical `retries <= 0` case where the
// loop never runs and `lastEmpty` stays undefined. The private callers
// only pass `retries = 5` today, but keeping the cast sound costs
// nothing.
return (lastEmpty ?? []) as T;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Defensive `?? []` covers the theoretical `retries <= 0` case where the
// loop never runs and `lastEmpty` stays undefined. The private callers
// only pass `retries = 5` today, but keeping the cast sound costs
// nothing.
return (lastEmpty ?? []) as T;
return [];

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied — dropped the lastEmpty tracking entirely. The post-loop now just return [] as T;. The cast is needed because TypeScript can't statically prove T extends Array from the runtime Array.isArray branch, but the function is private with one caller (workspace.findFiles returning Uri[]) so it's sound. Pushed in the latest force-push.

…bt project

Production telemetry surfaced cluster `extensionActivationError` (83 unique
machines / 104 events / 24h, both 0.60.7 and 0.61.0) with the stack pinned
at:

    Error: no projects found. retrying
        at DBTWorkspaceFolder.retryWithBackoff
        at async DBTWorkspaceFolder.discoverProjects
        at async DBTProjectContainer.registerWorkspaceFolder
        at async Promise.all (index 0)
        at async DBTProjectContainer.initializeDBTProjects
        at async DBTPowerUserExtension.activate

Distribution: ~95% darwin + linux, ~95% `core` integration mode, every
top-version contributing — consistent with the multi-root workspace
pattern common on dev machines, and inconsistent with a regression.

**Root cause.** VS Code's `workspaceContains:**/dbt_project.yml`
activation matches when ANY workspace folder contains a `dbt_project.yml`.
The extension then runs `discoverProjects` on EVERY workspace folder via
`Promise.all(folders.map(registerWorkspaceFolder))`. For a folder that
genuinely has no dbt project — typical for multi-root workspaces where a
sibling folder triggered activation — `findFiles` returns `[]` on every
attempt, `retryWithBackoff` exhausts after ~10s (5 attempts with 1+2+3+4
backoff), and the thrown `"no projects found. retrying"` propagates up
to the activate-level catch.

The visible damage isn't just the telemetry noise: the throw also
short-circuits the rest of `activate()`, so `statusBars.initialize()`
and the workspace-folder change listener never run. Users with a
mixed-folder workspace get a working dbt project for the folder that
matched, plus silently-degraded status-bar UX for the rest of the
session.

**Fix.** In `retryWithBackoff`, treat a consistently-empty result after
the retry budget as legitimately empty and return `[]` instead of
throwing. The retry schedule and the empty/non-empty differentiation
are unchanged — only the terminal disposition. Real errors from `fn()`
(permission failures, malformed pattern, indexer crashes) still throw
after retries, preserving the existing escalation for non-empty failure
modes. The constructor's `createConfigWatcher` keeps watching for
`dbt_project.yml` creation in this folder, so a project added later is
still picked up.

This also restructures the loop so empty results are handled by normal
control flow rather than via a thrown-and-caught sentinel error, which
is cleaner and removes the previously-unreachable
`"no projects found after maximum retries"` throw at the bottom of the
function.

**Verification.** Adds `src/test/suite/discoverProjectsRepro.test.ts`
with four cases that instantiate the real `DBTWorkspaceFolder` and stub
only `workspace.findFiles`:

  1. Consistently empty `findFiles` → `discoverProjects` resolves
     cleanly (was: rejects after ~10s).
  2. Retry budget unchanged at ~10s — backoff schedule identical to
     pre-fix.
  3. Multi-root `Promise.all([happy, empty])` resolves successfully —
     reproduces the production "Promise.all (index 0)" stack frame and
     proves the fix unblocks the rest of `activate()`.
  4. `findFiles` rejecting (e.g. EACCES) still throws after 5 retries —
     guards against accidentally swallowing genuine errors.

Pre-fix output captured at
`screenshots/no-projects-found-investigation/repro-output.txt`; post-fix
at `screenshots/no-projects-found-investigation/after-fix-repro.txt`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ralphstodomingo ralphstodomingo force-pushed the fix/multi-root-empty-folder-no-projects-found branch from 778f0b0 to f9422a9 Compare May 8, 2026 04:36
Comment thread src/dbt_client/dbtWorkspaceFolder.ts Outdated
Comment on lines 147 to 155
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we not just add try catch here?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And revert above change?

…ctsFound sentinel

Revert `retryWithBackoff` to its master shape and instead handle the
empty/error split at the call site in `discoverProjects`. A new
`NoProjectsFound` Error subclass is thrown for the consistently-empty
case, and `discoverProjects` catches at the boundary:

- `NoProjectsFound` → silent, resolves to `[]` (multi-root expected case)
- any other error → fires `discoverProjectsError` telemetry, resolves
  to `[]` so activation isn't short-circuited on a single folder

Telemetry uses the existing `sendTelemetryError` plumbing already in
this file (parity with `registerDBTProjectError`). Empty folders no
longer fire telemetry — avoids swapping the `extensionActivationError`
cluster for an equivalent `discoverProjectsError` cluster.

Test 4 flips from "real errors propagate" to "real errors resolve to
[] AND fire `discoverProjectsError` exactly once with the underlying
error preserved." Test 1 additionally asserts no telemetry on the
empty case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/test/suite/discoverProjectsRepro.test.ts`:
- Around line 73-79: The helper stubWorkspaceFindFiles mutates the shared
workspace object by replacing workspace.findFiles and workspace.getConfiguration
but never restores them; capture the original references at the start of
stubWorkspaceFindFiles (e.g., const origFindFiles = workspace.findFiles; const
origGetConfiguration = workspace.getConfiguration), then add an afterAll (or
afterEach for per-test isolation) in the test file to restore
workspace.findFiles = origFindFiles and workspace.getConfiguration =
origGetConfiguration so other suites are not affected; ensure the restoration
references the same originals captured when the stub was installed and run
cleanup unconditionally.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 18d75ab9-ce5a-4bd3-8ddb-a79a8e63403f

📥 Commits

Reviewing files that changed from the base of the PR and between f9422a9 and fdee09a.

📒 Files selected for processing (2)
  • src/dbt_client/dbtWorkspaceFolder.ts
  • src/test/suite/discoverProjectsRepro.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/dbt_client/dbtWorkspaceFolder.ts

Comment thread src/test/suite/discoverProjectsRepro.test.ts
…amination

`stubWorkspaceFindFiles` mutates the shared `vscode` module object
(`workspace.findFiles`, `workspace.getConfiguration`) without ever
restoring them. Other suites in the same Jest worker would silently
pick up the last test's stub. Capture originals in `beforeAll` and
restore in `afterAll`.

Per CodeRabbit review on PR #1939.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ralphstodomingo ralphstodomingo requested a review from mdesmet May 8, 2026 08:23
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
src/test/suite/discoverProjectsRepro.test.ts (1)

161-186: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Test currently codifies the wrong contract for real findFiles failures

Line 176 should assert rejection, not resolution. Per the PR objective, only the consistently-empty case is swallowed; genuine findFiles errors should still escalate after retries. Keeping this as resolves would mask regressions and enforce behavior opposite to the intended fix.

Suggested test correction
 it("real findFiles errors resolve to [] AND fire discoverProjectsError telemetry", async () => {
@@
-  await expect(folder.discoverProjects()).resolves.toBeUndefined();
+  await expect(folder.discoverProjects()).rejects.toThrow(
+    "EACCES: permission denied",
+  );
   expect(findFilesMock).toHaveBeenCalledTimes(5);
   expect(calls).toBe(5);
@@
 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/test/suite/discoverProjectsRepro.test.ts` around lines 161 - 186, The
test incorrectly expects folder.discoverProjects() to resolve; change the
assertion to expect it to reject with the underlying error (e.g., use await
expect(folder.discoverProjects()).rejects.toThrow or
rejects.toHaveProperty('message', 'EACCES: permission denied')) so genuine
findFiles failures escalate after retries; keep the rest of the checks
(findFilesMock call count and fakeTelemetry.sendTelemetryError called once)
intact and reference folder.discoverProjects(), the stubbed findFilesMock, and
fakeTelemetry.sendTelemetryError when updating the assertion.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@src/test/suite/discoverProjectsRepro.test.ts`:
- Around line 161-186: The test incorrectly expects folder.discoverProjects() to
resolve; change the assertion to expect it to reject with the underlying error
(e.g., use await expect(folder.discoverProjects()).rejects.toThrow or
rejects.toHaveProperty('message', 'EACCES: permission denied')) so genuine
findFiles failures escalate after retries; keep the rest of the checks
(findFilesMock call count and fakeTelemetry.sendTelemetryError called once)
intact and reference folder.discoverProjects(), the stubbed findFilesMock, and
fakeTelemetry.sendTelemetryError when updating the assertion.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fe39be52-9467-4a8e-a575-c7ab6d11e09c

📥 Commits

Reviewing files that changed from the base of the PR and between fdee09a and 8143ed0.

📒 Files selected for processing (1)
  • src/test/suite/discoverProjectsRepro.test.ts

@dev-punia-altimate
Copy link
Copy Markdown
Contributor

✅ Tests — All Passed

cc @ralphstodomingo

);
} catch (error) {
if (!(error instanceof NoProjectsFound)) {
this.telemetry.sendTelemetryError("discoverProjectsError", error);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not really an error that we need to track though. this just means that there is no projects found?

Copy link
Copy Markdown
Contributor Author

@ralphstodomingo ralphstodomingo May 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mdesmet not the case.

!(error instanceof NoProjectsFound) means it's deliberately ignoring no projects found here

there was an activation-level catch that was surfacing all of these, both the 'no projects found' and everything else findFiles could reject for (EMFILE, EACCES, etc)

this PR makes it so it no longer throws an error that's caught there when it's just no dbt projects found, but without this, errors that the old structure would have caught at activation level would just be silently ignored

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants