Skip to content

perf(startup): stop unnecessary engine disposes, unblock first load on restricted networks, add startup benchmark - #3118

Open
benjaminshafii wants to merge 7 commits into
devfrom
perf/cold-start-mcp
Open

perf(startup): stop unnecessary engine disposes, unblock first load on restricted networks, add startup benchmark#3118
benjaminshafii wants to merge 7 commits into
devfrom
perf/cold-start-mcp

Conversation

@benjaminshafii

Copy link
Copy Markdown
Member

Why

Users hit Error("Not connected") when the agent calls an openwork-cloud MCP tool mid-conversation. Root cause, verified against the shipped opencode 1.17.11 binary: MCP.convertTool captures the MCP Client object in each tool's execute closure at turn start. If that client is closed mid-turn, the captured client has _transport === undefined and the SDK throws "Not connected".

POST /instance/dispose closes every MCP client and SIGTERMs every local/stdio MCP child. Most of the disposes that cause this are self-inflicted. We cannot patch the pinned engine, so this PR stops closing those clients unnecessarily, and adds the measurement tooling that made the problem visible.

Guiding principle: if the user is logged in, cloud capabilities are loaded and stay loaded. No new pre-send gate — see "Rejected" below.

What changed

Stop disposing the engine unnecessarily

  • MCP add / toggle / remove already apply surgically in-request (syncRuntimeMcpToOpencodeEngine / disconnectMcpFromOpencodeEngine), but also emitted a reload event and the renderer called markReloadRequired("mcp") — coalescing into a full dispose ~1.5 s later. The reload event is now emitted only when the surgical sync actually failed (real inspection of EngineMcpSyncResult), and the three redundant renderer calls are gone.
  • resolveWorkspace() could fire a fire-and-forget dispose from any workspace-scoped API request (via repairCommands), errors swallowed. repairCommands still runs unchanged; it now emits a gated "commands" reload event instead, so the change still reaches the engine but through the reload coordinator's idle gate.
  • Provider OAuth called refreshProviders({ dispose: true }) on every 2 s poll iteration for up to 15 s (up to 8 disposes for one OAuth). Polling is now non-disposing; the engine is disposed at most once, after the provider is detected connected. The raw instance.dispose() fallback — which skipped MCP re-registration and silently dropped non-primary-workspace MCPs — is now a genuine last resort.

First-load / air-gap correctness

  • ArchitectureMismatchGate rendered null for the entire app tree while Electron main did an un-timeouted fetch to GitHub releases over Node undici (no OS trust, no system proxy). On a stalled corporate proxy that blanks the window for minutes. Now: net.fetch (OS trust + proxy) with a 5 s abort, plus a bounded wait in the gate. Fail-open preserved.
  • opencode-chrome-devtools was a bare npm specifier in the engine plugin array — a runtime npm install on first launch, which cannot work air-gapped. It is now bundled to a local file like the other five plugins, pinned to 1.0.4, with a fallback to the old specifier if the bundled file is missing.

Measurement (new)

  • startup-trace modules for Electron main + the in-process server: NDJSON, no-op unless OPENWORK_STARTUP_TRACE is set, buffered with a sync flush on exit/SIGTERM/SIGINT, and never throws. There was previously no timing instrumentation at all in the main process.
  • scripts/bench-cold-start.mjs launches the app repeatedly and reports min/median/p95/max for window / openworkServer / opencode / mcpSynced / cloudConnected, with --home fresh|shared (first-launch-ever vs relaunch) and --compare that refuses mismatched profiles.

Measured (Daytona Linux, medians)

Cold start, seeded with 3 MCPs, first-launch-ever (--home fresh):

metric before after
first pixels 337 ms 346 ms
opencode listening 1539 ms 1542 ms
MCP synced 10 472 ms 10 494 ms

The latency numbers did not move, and I am not claiming they did. Bundling the plugin removes a network dependency, not wall time. Isolating the engine with its real generated config showed why:

config cold GET /mcp
real config, plugin array removed 98 ms
real config, OPENWORK_SERVER_URL unset 4234 ms
real config, server URL → dead port 3890 ms

~4.1 s of first-launch cost is plugin loading, essentially all of it opencode npm-installing 28 packages into ~/.config/opencode/node_modules on a fresh HOME. With plugin: [] it installs nothing. Removing chrome-devtools' own install (verified: no ~/.cache/opencode/packages entry created) is dwarfed by that shared install, which is also the real air-gap blocker. That is the next lever and it is not attempted here — the engine installing its own version-matched @opencode-ai/plugin is plausibly safer than us bundling a mismatched copy.

Verification

  • pnpm --filter openwork-server test531 pass, 0 fail
  • pnpm --filter @openwork/app test424 pass, 0 fail
  • pnpm --filter @openwork/desktop test113 pass, 0 fail
  • pnpm --filter openwork-server typecheck, pnpm typecheck, pnpm --filter openwork-server build → pass

New tests: the three MCP routes emit no reload event when surgical sync succeeds and do emit one when it fails; resolveWorkspace records a "commands" event and issues no /instance/dispose (asserted against a mock engine that records request paths); provider OAuth polling triggers at most one reload.

No tool regression from bundling. Same engine, same dist, only plugin[0] differing — /experimental/tool/ids returns a byte-identical 27-tool list including all 8 browser_* tools, and the after run creates no npm cache entry.

fraimz

core-flow on this branch: frames 1–3 pass (boots to a usable session surface, creates a task, sends a message and gets a response). Frame 4 ("closes and reopens; session survives") fails.

I ran the identical flow on unmodified dev in the same sandbox as a control, and it fails on the same frame with the same error — so this is pre-existing, not introduced here. It may be environment-specific (Daytona, dev mode, free model) or a real persistence bug; either way it deserves its own investigation.

  • branch run: evals/results/2026-07-25T13-08-17-915Z/fraimz.html
  • dev control: evals/results/2026-07-25T13-15-37-826Z/fraimz.html

Both preserved on the artifacts volume under validation/cold-start-mcp/.

Rejected: re-enabling the pre-send Cloud MCP gate

session-route.tsx still carries skipGate: true. Re-enabling it would be actively harmful: assessCloudMcpSubmissionReadiness requires tools.direct.checked === true, i.e. a successful server-side probe — which cloud-mcp-health.ts explicitly declares non-authoritative precisely because it fails on corporate TLS interception, where the engine's own MCP connection works fine. That would block every message in exactly the air-gapped/intercepted fleets we need to support. Fixing the causes is the right move; the gate stays off.

Follow-ups (evidence in hand, not done here)

  1. The 28-package plugin dependency install (~4.1 s, and a hard air-gap blocker). Biggest remaining first-load lever.
  2. Recover from "Not connected" instead of dead-ending. attributeChatToolError has no branch for "Not connected" / "Connection closed", so the tool card renders a bare error with no reconnect or retry. A targeted, non-destructive POST /workspace/:id/mcp/openwork-cloud/engine-refresh already exists and is currently only reachable from a Settings button.
  3. Status-first MCP sync. The startup POST loop re-registers entries the engine already has from OPENCODE_CONFIG; the first POST forces full engine init and posts 2–3 cost ~577 ms each of pure waste. One GET /mcp then POST only what is missing.
  4. Desktop CA export is weaker than the installer's. runtime.mjs uses only tls.getCACertificates("system"); apps/installer/src/system-ca.ts additionally enumerates Windows cert stores and macOS keychains because the runtime returns a partial root set on inspected networks.
  5. Two bare fetch calls bypass OS trust (agent-context-cloud-probe.ts, agent-context-diagnostics.ts), and no-bare-fetch.test.ts's regex misses ?? fetch.
  6. Skills/commands changes still force a full dispose. Deliberately untouched: Skill.state/Command.state are memoized per instance with no update endpoint, so skipping the reload would silently stop new skills appearing. Needs engine-side granular invalidation.

…mark harness

Adds NDJSON startup tracing to the Electron main process, the in-process
OpenWork server, and the MCP sync/cloud-reconcile paths. There was previously
no timing instrumentation at all in the main process, so cold-start work could
not be measured.

- apps/desktop/electron/startup-trace.mjs + apps/server/src/startup-trace.ts:
  no-op when OPENWORK_STARTUP_TRACE is unset, buffered async flush with a
  synchronous flush on exit/SIGTERM/SIGINT, and never throws.
- Instruments boot phases, opencode spawn/listen, openwork server listen,
  per-entry MCP POST registration, and the cloud MCP poll ladder.
- scripts/bench-cold-start.mjs launches the app repeatedly and reports
  min/median/p95/max for window, openworkServer, opencode, mcpSynced and
  cloudConnected, with --compare for before/after deltas.

Measurement only: no behavior change.
# Conflicts:
#	apps/desktop/electron/runtime.mjs
#	apps/server/src/embedded.ts
@vercel

vercel Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
openwork-app Ready Ready Preview, Comment Jul 25, 2026 1:17pm
openwork-den Ready Ready Preview, Comment Jul 25, 2026 1:17pm
openwork-den-worker-proxy Ready Ready Preview, Comment Jul 25, 2026 1:17pm
openwork-diagnostics Ready Ready Preview, Comment Jul 25, 2026 1:17pm
openwork-landing Ready Ready Preview, Comment, Open in v0 Jul 25, 2026 1:17pm

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.

1 participant