Dev desktop launch redoes ~40 s of unconditional work every time: the goose SDK is built twice and the Vite dep cache is deleted on every start
- Lingua principale
- Rust
- Stelle
- 54.2k
- Fork
- 6.2k
- Merge medio
- 3g 2h
- PR unite (30g)
- 262
Descrizione
**Describe the bug**
`pnpm run start-gui` (and therefore `just run-ui` / `just run-ui-only`) performs the same ~40 s of
JavaScript work on **every** launch, including a launch where nothing at all has changed since the
previous one. None of it is freshness-checked, and one step deletes a cache that nothing needed
deleted — costing 11–18 s to refill each time.
Three separate things happen on each start:
**1. `build-goose-sdk` runs twice.**
```jsonc
// ui/desktop/package.json
"postinstall": "pnpm run build-goose-sdk",
"build-goose-sdk": "pnpm --filter @aaif/goose-sdk run build && pnpm run clean-vite-cache",
"start-gui": "pnpm run build-goose-sdk && pnpm run i18n:compile && electron-forge start",
```
`just run-ui` runs `pnpm install && pnpm run start-gui`, so the SDK is generated and type-compiled
once from `postinstall` and immediately again from `start-gui`. The second run is pure waste — the
inputs cannot have changed in between. Neither run checks whether anything changed at all: the
generator's inputs are `crates/goose/acp-schema.json` and `crates/goose/acp-meta.json`
(`ui/sdk/generate-schema.ts`), plus `ui/sdk/src/`, and an unchanged tree rebuilds regardless.
**2. The Vite dependency cache is deleted on every launch, and nothing needs it deleted.**
`build-goose-sdk` ends in `clean-vite-cache`, which `fs.rmSync`s `node_modules/.vite`,
`node_modules/.vite-temp` and `.vite`. So Vite's dependency optimizer starts from zero every single
time — 42 dependencies, a 36 MB cache, 11–18 s to rebuild.
The script's premise is that the dev server may be holding a pre-bundled copy of an
`@aaif/goose-sdk` build that has since been replaced. **That is already prevented elsewhere, by a
change that landed nine days earlier:** `optimizeDeps.exclude: ['@aaif/goose-sdk']` in
`ui/desktop/vite.renderer.config.mts` (#9951, `f38f1ace`, 2026-06-23), whose comment describes
exactly this failure. `clean-vite-cache.js` arrived afterwards in the ACP migration (#10081,
`b83b194d`, 2026-07-02).
I measured whether the exclude actually holds, rather than assuming:
| Probe | Result |
|---|---|
| Is the SDK in the optimizer's manifest? | **No** — `node_modules/.vite/deps/_metadata.json` lists 42 optimized deps; `@aaif/goose-sdk` is not one of them |
| Is there a pre-bundled copy to serve? | **No** — `GET /node_modules/.vite/deps/@aaif_goose-sdk.js` on the dev server returns **404** |
| Does a rebuilt SDK reach the renderer with the cache left in place? | **Yes** — added a marker export to `ui/sdk/src/index.ts`, ran `pnpm --filter @aaif/goose-sdk run build`, left the 36 MB cache untouched, restarted, and `GET /@fs//ui/sdk/dist/index.js` served the marker |
So the clean is discarding an expensive cache on every launch to protect an invariant another file
already holds. Nor is there a second staleness class hiding behind it: Vite keys the optimized-deps
cache on the lockfile and the resolved `optimizeDeps` config, so a dependency bump or a change to
the exclude list re-optimizes on its own — including for anyone whose cache predates #9951.
Three further points about that same script:
- The third path it removes, `ui/desktop/.vite`, is not a cache — it is the Electron Forge Vite
plugin's **build output** (`.vite/build`, and `.vite/` is in `ui/desktop/.gitignore`). That
directory *is* worth clearing, but at install time it is both the wrong moment and the wrong
reason. The plugin builds with `emptyOutDir: false` (`@electron-forge/plugin-vite`'s
`vite.base.config`), so anything that stops being emitted — a renamed asset, a dropped entry —
lingers and is copied into the app. Installs are irrelevant to that; **packaging** is what needs
the guarantee.
- `node_modules/.vite-temp` is the optimizer's own scratch directory: Vite writes the new bundle
there and renames it into place, and cleans it up itself. A leftover only appears when a run was
killed mid-optimize, and the next run overwrites it. Deleting it every launch buys nothing that
Vite does not already do.
- On macOS the recursive delete races with the filesystem: Finder or Spotlight can plant a
`.DS_Store` into a directory `rmSync` has already emptied, and `rmSync` then throws `ENOTEMPTY`,
failing the whole `pnpm install`. Seen in practice.
**3. `i18n:compile` is unconditional.** 16 locales, one `formatjs` child process each, ~4.7 s, with
no check against the source message files' mtimes. Smaller than the other two, but the same shape.
---
**Measured breakdown**
Apple Silicon MacBook, 16 GB, warm caches, **no source changes between runs** — i.e. this is the
cost of launching a tree that is already fully built. Each step timed on its own:
| Step | Time | Freshness-checked? |
|---|---|---|
| `pnpm install --frozen-lockfile` (→ `postinstall` → `build-goose-sdk` → `clean-vite-cache`) | 19.5 s | ❌ |
| — of which the bare install (`--ignore-scripts`) | 4.4 s | ✅ (pnpm no-op) |
| — of which the SDK build itself | ~10 s | ❌ |
| — remainder: pnpm's own lifecycle/link overhead | ~5 s | n/a |
| `start-gui`'s **second** `build-goose-sdk` | 9.6 s | ❌ |
| `i18n:compile` (16 locales) | 4.7 s | ❌ |
| Vite dep pre-bundle from scratch (`vite optimize --force`, 42 deps → 36 MB) | 11–18 s | ❌ (cache was just deleted) |
| Vite dep pre-bundle with the cache present (`vite optimize`) | 1.5 s | ✅ |
Work a no-op launch performs that it did not need to: **~24 s of JS** (two SDK builds plus i18n)
**and 10–16 s of avoidable Vite refill**, so roughly **35–43 s** — a large fraction of a ~90 s no-op
`just run-ui`. The Rust side is properly incremental and is *not* part of this: `cargo build
--release -p goose-cli` on an unchanged tree is a couple of seconds.
Splitting the SDK build, since it decides which fix is the right one (four runs, same machine):
| Sub-step | Time | Incremental? |
|---|---|---|
| `generate` — `tsx generate-schema.ts` → `@hey-api/openapi-ts` → prettier | 2.2–5.0 s | no, and no mode for it |
| `build:ts` — `tsc` (`declaration` + `declarationMap`, no `incremental`) | 6.7–9.7 s | not configured today |
---
**To Reproduce**
1. Build the desktop app once so everything is warm: `just run-ui`, wait for the window, quit.
2. Change nothing.
3. `cd ui/desktop && time pnpm install --frozen-lockfile` → ~20 s, and note it rebuilds the SDK.
4. `time pnpm run build-goose-sdk` → ~10 s, rebuilding the exact same unchanged output, and
deleting the Vite cache again.
5. `time pnpm run i18n:compile` → ~5 s for 16 unchanged locales.
6. `rm -rf node_modules/.vite && time pnpm exec vite optimize --config vite.renderer.config.mts --force`
vs. `time pnpm exec vite optimize --config vite.renderer.config.mts` → ~11–18 s vs ~1.5 s. Step 4
guarantees you always pay the first one.
To reproduce the probes in the table above, start the dev server (`pnpm run start-gui`) and, from
another shell:
```bash
META=ui/desktop/node_modules/.vite/deps/_metadata.json
jq '.optimized | keys | length' "$META" # 42
jq '.optimized | has("@aaif/goose-sdk")' "$META" # false
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:5173/node_modules/.vite/deps/@aaif_goose-sdk.js
```
---
**Expected behavior**
A launch with no source changes should do approximately no work: the SDK is rebuilt only when its
inputs changed, it is not built twice in one launch, and Vite's dependency cache is not thrown away
by a launch at all.
---
**Proposed fix**
I have this working on a branch and am happy to open the PR. It is four changes; the first two are
independent of the rest and could land on their own.
1. **Delete `clean-vite-cache.js` and its call.** Per the probes above, `optimizeDeps.exclude`
already guarantees what it was written to guarantee, and Vite's own cache keying covers the rest.
Removing it makes "the dependency cache survives a launch" unconditional rather than conditional,
and takes the `ENOTEMPTY`-fails-your-install failure mode with it.
2. **Move the `.vite` removal to where it belongs** — a small `clean-vite-build.js` called from the
`package` and `make` scripts, not from `postinstall`. `start` is left alone; its output is
rebuilt every run. A failure there is fatal, deliberately: packaging a bundle whose contents you
cannot account for is worse than a failed package run.
3. **Put a freshness gate in front of the SDK build, for launches only** — a
`ui/desktop/scripts/build-goose-sdk.js` that hashes the real inputs
(`crates/goose/acp-schema.json`, `crates/goose/acp-meta.json`, `ui/sdk/generate-schema.ts`,
`ui/sdk/tsconfig.json`, `ui/sdk/package.json`, `ui/sdk/src/**`, `ui/pnpm-lock.yaml`), compares
them against a stamp from the last successful build, and only then runs today's
`pnpm --filter @aaif/goose-sdk run build`. Both existing call sites stay — neither is removable,
`postinstall` is what makes a fresh clone's `typecheck`/`lint:check` work and `start-gui` is what
picks up edits since the last install — but the second becomes a hash check. Measured:
9.6 s → 0.9 s.
**`package` and `make` opt out**, via a `build-goose-sdk:force` script (also reachable as
`--force` or `GOOSE_SDK_FORCE_BUILD=1`). The gate's input list is a hand-maintained restatement
of what the build reads; it is complete today, but nothing enforces that it stays complete, and
the cost of it silently drifting is asymmetric. On a launch, a stale SDK is a few seconds and a
`--force` away. In an artifact, it is a UI whose generated ACP dispatch disagrees with the
backend's schema — which `typecheck` will not catch, because it typechecks against the stale
generated types. CI is not the exposure (`ui/sdk/dist` is gitignored, so a fresh checkout has
nothing to skip with); a `pnpm run make` on a developer machine with a warm `dist` is. Ten
seconds on a run that already spends minutes on Rust and signing is not worth the argument, and
the entire launch win is untouched, since launches still go through the gate.
4. **Optionally, make `i18n-compile.js` skip locales whose compiled output is newer than its
source.**
Two things about (3) worth stating up front, since they are the first questions I would ask:
- **Fresh clones, `git clean` and CI still build.** The stamp lives in
`ui/desktop/node_modules/.cache/`, not in `ui/sdk/dist` — a machine-local hash has no business in
a published tarball (`ui/sdk/package.json` has `files: ["dist"]`, and `publish-npm.yml` installs
before it publishes). One consequence is worth being explicit about: the bundle workflows cache
`ui/desktop/node_modules` (with `restore-keys`, so a stamp can arrive from an unrelated run),
while nothing caches `ui/sdk/dist`. The `dist/index.js` existence check is therefore load-bearing,
not decorative.
- **Why not `tsc --build` / `incremental`?** Partly, you should — but it does not replace the gate.
On the numbers above, `tsc` is the larger half of the build, so making it incremental would help;
`generate` is the other 2–5 s, it has no incremental mode, and it rewrites `src/generated` on
every launch regardless. Best case that leaves ~5 s per invocation and two invocations per launch,
against 0.9 s for the hash check. I'm happy to add `incremental: true` to `ui/sdk/tsconfig.json`
in the same PR — it speeds up the builds that genuinely do run — but as a complement, not an
alternative.
---
**Please provide the following information**
- **OS & Arch:** macOS 14 (Sonoma), arm64 (Apple Silicon)
- **Interface:** UI (desktop dev build)
- **Version:** `goose-app` 1.41.1, `@aaif/goose-sdk` 0.20.2, pnpm 10.30.3, Node 24.10.0
- **Extensions enabled:** n/a — this is a build/launch issue, reproducible on a fresh checkout
- **Provider & Model:** n/a
---
**Additional context**
Timings are from a single run each on an otherwise idle machine, except the SDK sub-step split,
which is the range over four runs. Individual steps were reproducible within about a second, except
the Vite pre-bundle, which varied between 11 s and 18 s across three runs. Whole-launch wall-clock
is much noisier — 41–71 s to first React render across three identical runs — which is why the
breakdown times each step separately rather than diffing end-to-end launches. With the changes
above, a no-op launch to first React render came out around 30 s against around 45 s before, but
given that spread the per-step numbers are the firmer evidence.
Guida per i contributori
Apri la guida per i contributori
Valutazione
Questa issue non è ancora stata valutata.