Skip to content

feat(python-setup): recover from a constraint conflict with a Retry-as-DB-Connect fallback - #2181

Merged
rugpanov merged 9 commits into
rugpanov/python-setup-preset-pickerfrom
rugpanov/python-setup-conflict-fallback
Sep 9, 2026
Merged

feat(python-setup): recover from a constraint conflict with a Retry-as-DB-Connect fallback#2181
rugpanov merged 9 commits into
rugpanov/python-setup-preset-pickerfrom
rugpanov/python-setup-conflict-fallback

Conversation

@rugpanov

@rugpanov rugpanov commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Why

The uv-native "set up Python environment" Full preset pins the cluster's
dependencies, so uv sync can fail when those pins conflict with the user's own
dependencies. Today that surfaces as the generic E_PROVISION toast ("uv could
not resolve the project's dependencies… adjust your dependencies"), which leaves
the user to work out on their own that dropping the pins — the DB Connect
preset (--no-constraints) — would resolve it while keeping matched Python +
databricks-connect.

What

  • E_PROVISION_CONFLICT — a distinct result-model error code for a genuine
    pin-vs-local conflict (a generic sync failure stays E_PROVISION), with its
    own actionable copy: "The cluster dependencies conflict with your local
    dependencies, so uv sync couldn't resolve the environment."
  • Recovery UX — on a Full-preset conflict the generic toast is replaced with
    two buttons: Retry DB Connect setup and Open pyproject.toml. "Show
    Logs" is dropped from this toast (it's self-service — the channel is still
    revealed), and every other provision failure is unchanged.
  • Restore-then-retry — the conflict fails after the runtime pins were
    merged into pyproject.toml (diskMutated: true, pre-merge file saved to the
    CLI's backupPath). So Retry first restores that backup, then re-runs with
    the dbconnect preset (--no-constraints) — --no-constraints alone only
    stops the retry from re-adding pins, it wouldn't remove the ones already on
    disk. The restore is atomic (temp sibling + rename) and refuses a backupPath
    outside the project. With no backupPath, Retry isn't offered and the run
    falls back to the ordinary doc-link handling.
  • run action armPythonSetupErrorAction gains an in-process-callback
    arm alongside url/command, so the two runtime-stateful buttons reuse
    showError's existing plumbing (label de-dup, log reveal); showError gains an
    includeShowLogs option to drop the trailing button for this toast.
  • runGuarded / runResolved extracted from runSetup, so the retry
    re-enters the flow with the dbconnect preset directly — no re-prompt of the
    compute or the preset picker, and guarded so a click can't race an in-flight
    run (and the restore only fires when the retry actually runs). Because
    dbconnect skips constraints, the retry can't itself surface a recoverable
    conflict (no loop), and a stale toast's Retry no-ops once the project is ready.
  • Telemetry — a conflict_retry value on the existing
    python_env.setup.attempt trigger dimension marks recovery runs, paired with
    the python_env.setup.result outcome for the recovery success rate. No new
    event. A conflict is never report-worthy (it's the user's own dependencies).

Depends on the CLI conflict code (dormant until it ships)

E_PROVISION_CONFLICT is a CLI-emitted code that is not yet in a released
CLI
— the extension is coded against the contract, so the new recovery path is
fully unit-tested but stays inert (a real conflict still arrives as E_PROVISION
today) until the CLI emits the distinct code and populates backupPath on that
failure
(the restore-then-retry relies on it). This mirrors the stacked-on
picker PR's own posture. No end-to-end test is added for that reason.

Testing

  • test:unit: 1126 passing. New coverage: the conflict copy + fallback doc link,
    the run-action dispatch, openProjectFile, the controller conflict→retry
    flow (restore-before-retry ordering, --no-constraints + adopt on success,
    conflict_retry telemetry paired with its outcome, open pyproject at the run's
    cwd, reportOffered: false, missing-backupPath and non-Full fallbacks,
    restore-failure aborts without a retry attempt), and the atomic/in-project
    restore wiring.
  • tsc + eslint + prettier clean on changed files.

Stacked on #2179 — review/merge that first.

This pull request and its description were written by Isaac.

…s-DB-Connect fallback

*Why*

The uv-native "set up Python environment" Full preset pins the cluster's
dependencies, so uv sync can fail when those pins conflict with the user's own
dependencies. Today that surfaces as the generic E_PROVISION toast, leaving the
user to work out on their own that dropping the pins (the DB Connect preset)
would resolve it.

*What*

- Add the distinct E_PROVISION_CONFLICT code to the setup-local result model
  (the CLI reserves it for a genuine pin-vs-local conflict; a generic sync
  failure stays E_PROVISION) with dedicated, actionable copy.
- On a Full-preset conflict, replace the generic toast with three buttons:
  "Retry as DB Connect setup" (re-runs with --no-constraints, keeping matched
  Python + databricks-connect), "Open pyproject.toml", and "Show Logs". Other
  provision failures are unchanged.
- Add a run (in-process callback) arm to PythonSetupErrorAction so those
  runtime-stateful buttons reuse the existing showError plumbing; extract
  runGuarded/runResolved so the retry re-enters the flow with the dbconnect
  preset without re-prompting the compute or the preset picker.
- A conflict on a run that already skipped constraints falls back to the generic
  doc-link handling (no nonsensical, looping retry). The conflict is never
  report-worthy (it is the user's own dependencies).

*Verification*

- test:unit — 1110 passing (the 2 failures are the stale bundled-CLI version
  check, a local worktree artifact; CI fetches the pinned binary and passes).
- New unit coverage: conflict copy + fallback doc link, run-action dispatch,
  openProjectFile, and the controller conflict->retry flow (re-run drops the
  pins and adopts, dbconnect telemetry, open pyproject, reportOffered=false,
  and the non-Full fallback).
- tsc + eslint clean on changed files.

Co-authored-by: Isaac <no-reply@databricks.com>
@rugpanov
rugpanov deployed to test-trigger-is September 8, 2026 13:24 — with GitHub Actions Active
@rugpanov
rugpanov deployed to test-trigger-is September 8, 2026 13:25 — with GitHub Actions Active
@rugpanov
rugpanov deployed to test-trigger-is September 8, 2026 13:25 — with GitHub Actions Active
@rugpanov

rugpanov commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Integration tests running for 1dd3abbd — ⏳.
View run

…metry, and cover retry edge cases

*Why*

Multi-source review (Claude + Codex) flagged: E_PROVISION_CONFLICT silently
dropped the soft "report to databricks/environments" log pointer that the same
conflict carried while it was E_PROVISION; the retry re-entrancy and no-loop
guarantees were untested; and the retry's `initial` telemetry trigger deserved a
recorded rationale.

*What*

- Extend the constraints-report log pointer in formatSetupFailureDetail to cover
  E_PROVISION_CONFLICT (a pins-vs-local conflict is exactly where the published
  constraints may be at fault, and it carried the pointer before the CLI split
  the code out). Button behaviour is unchanged (still no report button).
- Add tests: the DB Connect retry failing again gets the generic action, never a
  second Retry (no loop); and a Retry click while another run is in flight
  coalesces onto it rather than spawning a concurrent CLI process. Coalescing
  (not queuing) is deliberate — a queued dbconnect retry could clobber a run
  about to succeed.
- Document why a constraint-conflict retry reports trigger `initial` (the failed
  Full run never marked the project ready; the `dbconnect` setupPreset
  distinguishes it).

*Verification*

- test:unit — 1113 passing (the 2 failures are the stale bundled-CLI version
  check, a local worktree artifact). New/updated specs for the log pointer, the
  no-loop path, and the in-flight coalescing all green.
- tsc + eslint + prettier clean on changed files.

Co-authored-by: Isaac <no-reply@databricks.com>
@rugpanov
rugpanov deployed to test-trigger-is September 8, 2026 13:45 — with GitHub Actions Active
@rugpanov
rugpanov deployed to test-trigger-is September 8, 2026 13:45 — with GitHub Actions Active
@rugpanov
rugpanov deployed to test-trigger-is September 8, 2026 13:45 — with GitHub Actions Active
@rugpanov

rugpanov commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Integration tests running for abc623c7 — ⏳.
View run

…et-up project

*Why*

Re-review (Claude) flagged a footgun: the constraint-conflict toast lingers in
the Notifications Center, so its "Retry as DB Connect" can be clicked long after
a separate run has since provisioned the project. With no run in flight, that
stale click would re-run with --no-constraints and silently downgrade the working
Full environment. This is the first toast action that re-enters the mutating
setup flow, so it introduces the hazard.

*What*

- The Retry callback now bails when the project is already set up
  (`readyRoots.has(cwd)`), so a stale click cannot re-provision. The immediate
  (intended) retry still runs — the failed Full run never marked the project
  ready. This also keeps the `trigger: "initial"` note accurate: the retry only
  ever runs while not ready.

*Verification*

- test:unit — 1114 passing (the 2 failures are the stale bundled-CLI version
  check, a local worktree artifact). New spec: a stale Retry after a later
  success does not re-run the CLI.
- tsc + eslint + prettier clean on changed files.

Co-authored-by: Isaac <no-reply@databricks.com>
@rugpanov
rugpanov deployed to test-trigger-is September 8, 2026 13:56 — with GitHub Actions Active
@rugpanov
rugpanov deployed to test-trigger-is September 8, 2026 13:56 — with GitHub Actions Active
@rugpanov
rugpanov deployed to test-trigger-is September 8, 2026 13:56 — with GitHub Actions Active
@rugpanov

rugpanov commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Integration tests ❌ failed for 8e97a6ec.
View run

*Why*

Local testing surfaced two UX rough edges on the new conflict toast: the retry
label overflowed the VS Code notification's button row (truncating a button),
and it read a little jargon-y on its own. Three buttons also crowded the row.

*What*

- Rename the retry action to "Retry DB Connect setup" — it mirrors the setup
  picker's "DB Connect setup" tier 1:1 (what the user would otherwise choose),
  and avoids CLI-internal wording ("constraints") that appears nowhere the user
  can see.
- Add an `includeShowLogs` option to the `showError` seam and pass `false` for
  the recoverable conflict, so its toast shows just
  [Retry DB Connect setup] [Open pyproject.toml] — a self-service row whose own
  buttons are the remedy. The output channel is still written and revealed, so
  the log stays reachable. Every other error toast keeps its Show Logs button.

*Verification*

- test:unit — 1119 passing. New/updated specs: `showError` omits Show Logs when
  `includeShowLogs` is false; the conflict path passes it while an ordinary
  failure does not; label assertions updated.
- tsc + eslint + prettier clean on changed files.

Co-authored-by: Isaac <no-reply@databricks.com>
@rugpanov
rugpanov deployed to test-trigger-is September 8, 2026 15:59 — with GitHub Actions Active
@rugpanov
rugpanov deployed to test-trigger-is September 8, 2026 15:59 — with GitHub Actions Active
@rugpanov
rugpanov deployed to test-trigger-is September 8, 2026 16:00 — with GitHub Actions Active
@rugpanov

rugpanov commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Integration tests running for 9eec94b8 — ⏳.
View run

@rugpanov
rugpanov marked this pull request as ready for review September 8, 2026 16:01
*Why*

To measure how often the "Retry DB Connect setup" button is clicked and how often
that retry then succeeds — reusing the existing 1:1 python_env.setup.attempt →
python_env.setup.result pairing rather than adding a new event. The only gap was
that a conflict retry is indistinguishable from a first-time DB Connect pick:
both record trigger "initial" + setupPreset "dbconnect". A discriminator on the
trigger dimension closes it.

*What*

- Add "conflict_retry" to PythonSetupRunTrigger; update the trigger comment on
  python_env.setup.attempt and the PythonSetupAttempt doc to describe it.
- Thread an explicit trigger through runResolved → recordAttempt. The normal path
  still derives readyRoots.has(cwd) ? "rerun" : "initial"; the retry re-entry in
  buildConflictRecoveryActions passes "conflict_retry". No structural change
  downstream — the paired result event already carries the outcome.

Resulting metrics: (a) clicks = attempts with trigger=conflict_retry;
(b) success = the paired result with outcome=ok; rate = (b)/(a), with the failure
breakdown free from the same result event. The two no-op click paths (stale /
guard-coalesced) record no attempt by design, so (a) stays the true denominator.

*Verification*

- test:unit — 1119 passing. The conflict→retry test now asserts the retry attempt
  records trigger "conflict_retry" and the paired result its outcome.
- tsc + eslint + prettier clean on changed files.

Co-authored-by: Isaac <no-reply@databricks.com>
@rugpanov
rugpanov deployed to test-trigger-is September 8, 2026 16:53 — with GitHub Actions Active
@rugpanov
rugpanov deployed to test-trigger-is September 8, 2026 16:53 — with GitHub Actions Active
@rugpanov
rugpanov deployed to test-trigger-is September 8, 2026 16:53 — with GitHub Actions Active
@rugpanov

rugpanov commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Integration tests ❌ failed for abd1385b.
View run

*Why*
The constraint-conflict "Retry DB Connect setup" recovery re-ran with
--no-constraints, but the conflict fails in the provision phase *after* the
runtime pins were already merged into pyproject.toml (diskMutated: true).
--no-constraints only stops the retry from re-adding the pins; it does not
remove the conflicting ones the first run already wrote, so the retry would
resolve against the same conflict.

*What*
- Add an injected restoreProjectFile(projectRoot, backupPath) seam; the
  production wiring copies the CLI's backupPath over <projectRoot>/pyproject.toml
  (no new temp backup — reuses PythonSetupResult.backupPath).
- Thread result.backupPath into buildConflictRecoveryActions. On Retry, restore
  the file first, then re-run runResolved(..., "dbconnect", "conflict_retry").
- Gate the recoverable-conflict path on a present backupPath: with no backup
  there is nothing to roll back to, so Retry is not offered and the run falls
  through to the generic doc-link action.
- Restore runs inside runGuarded, so a failed restore throws before the CLI
  spawns or a retry attempt is recorded; showError's action-error handling logs
  it. Trim the now-duplicated rationale comments to a single home.

*Verification*
- New/updated unit tests: restore-before-CLI ordering, skipConstraints still
  set, successful retry adopts + marks ready, restore failure prevents the second
  CLI run and retry telemetry, missing backupPath does not offer Retry, and the
  wiring copies the backup over pyproject.toml.
- Full unit suite green (1123 passing); tsc, ESLint, Prettier clean on changed files.

Co-authored-by: Isaac <no-reply@databricks.com>
@rugpanov
rugpanov deployed to test-trigger-is September 9, 2026 10:38 — with GitHub Actions Active
@rugpanov
rugpanov deployed to test-trigger-is September 9, 2026 10:39 — with GitHub Actions Active
@rugpanov
rugpanov deployed to test-trigger-is September 9, 2026 10:40 — with GitHub Actions Active
@rugpanov

rugpanov commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Integration tests running for cd2f6d05 — ⏳.
View run

*Why*
Review of the constraint-conflict recovery surfaced two hardening gaps in the
backup restore and two doc inaccuracies. The restore behavior itself (a full
revert to the pre-merge backup) is kept as designed.

*What*
- restoreProjectFile now writes atomically: copy the backup to a temp sibling,
  then rename over pyproject.toml, so an interrupted/failed copy can't leave the
  project file truncated (temp cleaned up on error).
- Reject a backupPath that resolves outside the project before copying, so a
  malformed CLI result can't land an arbitrary file as pyproject.toml.
- Telemetry: clarify that conflict_retry counts recovery runs that started, not
  raw clicks (a failed-restore/coalesced/stale click records none).
- README: note E_PROVISION_CONFLICT follows the same no-report policy as
  E_PROVISION and points at the restore-and-retry recovery.

*Verification*
- New wiring unit tests: atomic restore leaves no temp artifact; refuses an
  out-of-project backup and leaves pyproject.toml untouched; a failed copy leaves
  the file untouched with no temp left. Existing copy test still green.
- Full unit suite green (1126 passing); tsc, ESLint, Prettier clean on changed files.

Co-authored-by: Isaac <no-reply@databricks.com>
@rugpanov
rugpanov deployed to test-trigger-is September 9, 2026 11:00 — with GitHub Actions Active
@rugpanov
rugpanov deployed to test-trigger-is September 9, 2026 11:01 — with GitHub Actions Active
@rugpanov
rugpanov deployed to test-trigger-is September 9, 2026 11:02 — with GitHub Actions Active
@rugpanov

rugpanov commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Integration tests ❌ failed for d51cfc39.
View run

*Why*
A second review round flagged that the recoverable-conflict gate keyed on
`!== undefined`, so a (contract-forbidden) empty backupPath would still offer a
"Retry DB Connect setup" button that can only throw — the restore rejects an
empty/at-root path — while hiding "Show Logs". Truthiness is strictly safer.

*What*
- Gate the recovery on a truthy backupPath, so an empty string falls through to
  the ordinary doc-link handling (like a missing backup) instead of a broken
  Retry toast.
- Make PythonSetupRunTrigger a type-only import (CODE_CONVENTIONS §7, new code).

*Verification*
- New unit test: an E_PROVISION_CONFLICT result with backupPath "" gets the
  generic "Resolve dependency conflicts" doc link and keeps Show Logs.
- Full unit suite green (1127 passing); tsc, ESLint, Prettier clean on changed files.

Co-authored-by: Isaac <no-reply@databricks.com>
@rugpanov
rugpanov deployed to test-trigger-is September 9, 2026 12:07 — with GitHub Actions Active
@rugpanov
rugpanov deployed to test-trigger-is September 9, 2026 12:07 — with GitHub Actions Active
@rugpanov
rugpanov deployed to test-trigger-is September 9, 2026 12:09 — with GitHub Actions Active
@rugpanov

rugpanov commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Integration tests ✅ passed for b9a33038.
View run

@rugpanov
rugpanov requested a review from misha-db September 9, 2026 16:03
…ker' into rugpanov/python-setup-conflict-fallback

# Conflicts:
#	packages/databricks-vscode/src/python-setup/utils/errorMessages.ts
@rugpanov
rugpanov deployed to test-trigger-is September 9, 2026 16:28 — with GitHub Actions Active
@rugpanov
rugpanov deployed to test-trigger-is September 9, 2026 16:29 — with GitHub Actions Active
@rugpanov
rugpanov deployed to test-trigger-is September 9, 2026 16:29 — with GitHub Actions Active
@rugpanov

rugpanov commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Integration tests running for 807c823c — ⏳.
View run

@rugpanov
rugpanov merged commit 0a9ac53 into rugpanov/python-setup-preset-picker Sep 9, 2026
6 of 7 checks passed
rugpanov added a commit that referenced this pull request Sep 10, 2026
…s-DB-Connect fallback (#2186)

## Why

The uv-native "set up Python environment" **Full** preset pins the
cluster's
dependencies, so `uv sync` can fail when those pins conflict with the
user's own
dependencies. The CLI now emits a distinct **`E_PROVISION_CONFLICT`**
for that
case (shipped in **CLI v1.16.0**, already bundled on `main`). Rather
than the
generic `E_PROVISION` toast ("uv could not resolve the project's
dependencies…
adjust your dependencies"), offer a one-click recovery: re-run as **DB
Connect**
(`--no-constraints`), which drops the conflicting cluster-dependency
pins while
keeping matched Python + databricks-connect.

## What

- **`E_PROVISION_CONFLICT`** — a distinct result-model error code for a
genuine
pin-vs-local conflict (a generic sync failure stays `E_PROVISION`), with
its
own copy: *"The cluster dependencies conflict with your local
dependencies, so
  uv sync couldn't resolve the environment."*
- **Recovery UX** (Full-preset conflict only) — the generic toast is
replaced
with **Retry DB Connect setup** and **Open pyproject.toml**. "Show Logs"
is
  dropped from this self-service toast (the channel is still revealed).
- **Restore-then-retry** — the conflict fails *after* the runtime pins
were
merged into `pyproject.toml` (`diskMutated: true`, pre-merge file saved
to the
CLI's `backupPath`). So **Retry** first restores that backup, then
re-runs with
the `dbconnect` preset — `--no-constraints` alone would only stop the
retry
from re-adding pins, not remove those already on disk. The restore is
atomic
(temp sibling + rename) and refuses a `backupPath` resolving outside the
project. When `backupPath` is absent/empty (e.g. a no-op-merge re-run),
Retry
isn't offered and the run falls back to the ordinary doc-link handling.
- **Guards** — `runGuarded`/`runResolved` let the retry re-enter with
the
`dbconnect` preset directly (no re-prompt), coalesced so a click can't
race an
in-flight run; `dbconnect` skips constraints so the retry can't loop; a
stale
  toast's Retry no-ops once the project is ready.
- **Telemetry** — a `conflict_retry` value on the existing
`python_env.setup.attempt` `trigger` dimension marks recovery runs,
paired with
the `python_env.setup.result` outcome for the recovery success rate. No
new
event. A conflict is never report-worthy (it's the user's own
dependencies).

## Live against the shipped CLI

Unlike when this was first written, **CLI v1.16.0 (on `main`) emits
`E_PROVISION_CONFLICT`**, so the recovery path is now exercised
end-to-end, not
dormant. Note: the CLI only populates `backupPath` on the *first* merge;
a
re-run over an already-merged `pyproject.toml` is a no-op merge and
returns an
empty `backupPath`, in which case the run correctly falls through to the
generic
path (the truthy-`backupPath` guard).

## Testing

- `test:unit`: new coverage for the conflict copy + fallback doc link,
the
`run`-action dispatch, `openProjectFile`, the controller conflict→retry
flow
  (restore-before-retry ordering, `--no-constraints` + adopt on success,
`conflict_retry` telemetry paired with its outcome, open pyproject at
the run's
cwd, `reportOffered: false`, missing/empty-`backupPath` and non-Full
fallbacks,
restore-failure aborts without a retry attempt), and the
atomic/in-project
  restore wiring.
- `tsc` + `eslint` + `prettier` clean on changed files.

Supersedes #2181, which merged into the (now-defunct) preset-picker
branch rather
than `main`.

This pull request and its description were written by Isaac.

Co-authored-by: Isaac <no-reply@databricks.com>
@github-actions github-actions Bot mentioned this pull request Sep 10, 2026
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.

2 participants