Skip to content

fix(provider): represent provider install/init lifecycle in the UI - #811

Draft
skevetter wants to merge 11 commits into
feat/async-workspace-upfrom
feat/async-provider-lifecycle
Draft

fix(provider): represent provider install/init lifecycle in the UI#811
skevetter wants to merge 11 commits into
feat/async-workspace-upfrom
feat/async-provider-lifecycle

Conversation

@skevetter

Copy link
Copy Markdown
Contributor

Note

Stacked on #798. Base is feat/async-workspace-up, so this diff shows only the provider work. Merge #798 first; the base then retargets to main automatically.

Adding a provider showed a red "not initialized" badge for several seconds before flipping to "initialized".

provider add --use=false persists the provider to config.yaml before init runs (cmd/provider/add.go returns early, skipping the only code that sets Initialized = true), and the watcher faithfully reported that. parseProviderEntries collapsed "not yet known" and "genuinely not initialized" into the same false, so the card rendered the destructive badge for both.

The existing mitigation was a renderer-local pending set that only covered init, and was marked after providerAdd had already returned — missing the entire multi-second install.

Approach

Two lifecycle axes were collapsed into one boolean. They're now separate:

  • Persisted truth stays Initialized bool on disk — "did this binary run Exec.Init". No schema change, no migration.
  • Transient job state moves to a main-process ProviderJobs registry, fed by the CLI's structured status events and broadcast on the existing providers-changed channel. Main-process ownership means it survives the wizard closing, navigation, and window reload — exactly when a long install is still running.

This also distinguishes states the flag cannot: installing vs initializing vs updating, and failed vs never-initialized.

Also included

  • provider add/init/set-source emit structured progress (pkg/status Phase/Event/Reporter, as workspace up does), replacing the desktop's "Exit code: 0" string-sniffing. pkg/devcontainer/statuspkg/status, since it's no longer devcontainer-specific.
  • Correctness fix: set-source left Initialized stale, so a provider updated to a new binary still reported initialized: true though the new binary's init never ran — a false positive admitting an uninitialized provider through loadInitializedProvider. Update and version-pin now chain provider init.
  • runStreaming hang: it registered close but not error, so a spawn failure never invoked onExit — callers hung forever and the concurrency slot leaked. Affected every streaming command, not just providers.
  • Tooling: task cli:build:grpc pinned generator versions that didn't match the committed .pb.go files and didn't put the plugin dir on PATH, so it failed or produced a 162-line phantom diff.

Verification

Driven against the running Electron app, not just tests — that's what caught two bugs the unit tests missed (a job cleared before the provider list refreshed, and a premature ready phase).

  • E2E 50/50 from both clean and dirty mock state
  • Unit 306 pass; typecheck (main + renderer) and golangci-lint clean
  • Each fix verified to fail without it, including the two races

A CodeRabbit review of this branch surfaced 5 findings (3 major); all are addressed in c6c1cbb.

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 244db308-c11a-4cd9-9b83-fcb527ee948e

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

@skevetter
skevetter force-pushed the feat/async-provider-lifecycle branch from c6c1cbb to 79efbc9 Compare July 29, 2026 23:43
@skevetter
skevetter force-pushed the feat/async-provider-lifecycle branch 4 times, most recently from 8fe37ca to fe69ca6 Compare July 30, 2026 02:49
skevetter added 11 commits July 30, 2026 09:20
updateProvider re-resolved the source and downloaded replacement binaries
but never touched Initialized, so a provider updated via
`provider set-source <name> --use=false` kept reporting initialized: true
even though the new binary's Exec.Init had not run. loadInitializedProvider
and the provider list both trust that flag, so workspace create would admit
a provider that was never initialized against its current source.

Reset the flag on update. The default --use=true path reloads config and
re-runs ConfigureProvider afterwards, so it still ends up initialized;
only the install-without-init path is now reported accurately.

Covers the --version pin path too, which routes through UpdateProvider.
Two defects made `task cli:build:grpc` unusable as documented:

The pinned generators (protoc-gen-go v1.36.4, grpc v1.5.1) were not the
ones that produced the committed .pb.go files (v1.36.11, v1.6.2), so
running the task downgraded the output and produced a 162-line diff
unrelated to any actual change.

It also installed the plugins to GOPATH/bin without putting that on PATH,
so protoc could not resolve them and the task failed outright on a machine
that did not already have them installed.

Pin the versions the committed output was generated with and export the
plugin dir. The task now regenerates byte-identical output.
Provider install/init will emit the same Phase/Event stream workspace up
does. Phase names alone cannot tell the two apart, so a shared sink has no
way to route or filter them.

Add Pipeline to status.Event and the NDJSON StatusEnvelope. Producers
declare their pipeline once via ForPipeline rather than at every
Enter/Leave/Fail call site, so existing reporting code is untouched.

Deliberately not on the tunnel's StatusUpdate: the tunnel exists for
container-side reporting, and provider commands run host-side, so no
provider event can reach it. The host stamps the pipeline when it builds
its reporter, which would overwrite any wire value anyway. Adding the
field would have committed us to a CLI<->agent wire surface that nothing
reads.

Parsing tolerates a missing pipeline so status lines from an older CLI are
still recognized.
Provider add/init/set-source emitted only freeform log text, so the
desktop had to sniff "Exit code: 0" out of the log stream to decide a
command had finished. Emit the same Phase/Event stream workspace up does,
tagged with the provider pipeline.

Phases: installing_provider covers source resolution and binary download;
resolving_options and running_init split provider init, since only the
latter executes provider-supplied code.

PhaseReady is emitted only when the provider is actually usable. The
--use=false paths on add and set-source install without initializing, and
callers chain `provider init` afterwards, so reporting ready there would
signal completion while the provider was still uninitialized.
Adding a provider flashed "not initialized" for several seconds. The badge
read the persisted `initialized` flag, which `provider add` writes as false
before init runs, and the watcher faithfully reported that. The existing
mitigation was a renderer-local pending set that only covered init, and was
marked after `provider add` had already returned — so it missed the whole
multi-second install.

Move lifecycle ownership into the main process: a ProviderJobs registry fed
by the CLI's status events, broadcast on the same providers-changed channel
as the provider list so the two cannot arrive out of order. Job state now
survives the wizard closing, navigation, and window reload, which is exactly
when a long install is still running.

The registry also distinguishes states the flag cannot: installing vs
initializing vs updating, and failed vs never-initialized.

Update and version-pin now chain `provider init`, since both swap binaries
and clear the flag; without it the badge would correctly, but unhelpfully,
stay red until the user initialized by hand.
Drives the real Electron app through add+init and samples the card
continuously, asserting the red "not initialized" badge never appears and
that a busy label does. The test fails against the pre-fix precedence, so
it genuinely covers the reported bug rather than restating the new code.

Writing it exposed a gap the unit tests could not: a finished job cleared
immediately while the provider list was only re-read on the next 3s poll,
briefly exposing the same red badge. ProviderJobs.finish now refreshes the
list before clearing, and the streaming init path awaits that refresh
before telling the wizard it is done.

The mock CLI now emits provider status envelopes and persists the
uninitialized-then-initialized transition, matching the real pipeline.
Init is slowed only for *probe providers, since a new card takes ~1.5s to
render and there would otherwise be no in-flight window to observe.
app.e2e asserts the mock fixture's exact counts (2 workspaces, 2 providers)
but never reset the mock's persisted state, so any spec that created or
deleted a workspace in an earlier run left it asserting against inherited
data. It read 3 instead of 2 whenever the state file survived.

Other specs that depend on the fixture already call resetMockState; do the
same here.
The renderer decided whether a command succeeded by string-matching its
final log line against "Exit code: 0". That is fragile in itself, and it
became a real hazard once provider commands started emitting NDJSON status
lines: filtering those out of the log stream could remove the very text the
check depended on.

The main process already knows the exit code, so have it say so. Add
success to the command-progress payload and set it at every terminal event;
isCommandSuccess now prefers it and only falls back to sniffing for events
emitted without it.
provider_add deliberately leaves its job open so the badge stays busy across
the add→init handoff. Nothing closed it when init never ran, so skipping
initialization or closing the wizard mid-flow left the card spinning on
"installing…" until the app restarted.

Add provider_release_job and call it from the wizard's skip and reset paths.
Releasing a job preserves an already-recorded failure, so closing the wizard
after a failed init still reports the failure rather than quietly clearing it.

Covered both in unit tests over the registry and an e2e case that drives the
real app; both fail without the fix.
Every handler opened a job and closed it by hand in matching try/catch
blocks. That is how the stuck-spinner bug happened: one path forgot, and a
provider stayed busy forever.

Add withProviderJob, which starts a job, closes it when the work resolves,
and records the error when it rejects. The four self-contained handlers use
it, so forgetting is no longer possible there.

Two sites still manage jobs directly because the job outlives the call by
design: provider_add hands off to the wizard's chained init, and
provider_init_streaming returns as soon as the child spawns and closes from
onExit. Both now say so, and provider_release_job covers the handoff.

Tests drive the job lifecycle through the IPC handlers; the add-handoff and
release cases fail if add closes its own job.
Five issues from a CodeRabbit review of the provider lifecycle work:

runStreaming registered "close" but not "error", so a spawn failure (missing
binary, EACCES) never invoked onExit. Callers that wrap it in a promise hung
forever with the job stuck busy, and the concurrency slot leaked. Report the
failure as an exit instead, guarding against a double report.

finish() awaited a CLI round-trip and then deleted by name without checking
the entry was still its own, so a job started during that window was cleared
out from under a running command — its phase reports then no-op'd and the
card looked idle. Track a generation per start() and verify before deleting.

The wizard released the job even while init was still running, dropping the
status of a live command. Only release when nothing is in flight; a failed
job stays safe either way since finish() preserves the failure.

Also: render the uninitialized badge in the provider sheet, which showed no
lifecycle badge at all in that state, and re-read state inside the mock CLI's
deferred init so a stale snapshot can't clobber a concurrent spec's write.
@skevetter
skevetter force-pushed the feat/async-provider-lifecycle branch from fe69ca6 to 551c499 Compare July 30, 2026 14:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant