Skip to content

Update when agents finish: deferred self-update for web/VPS and desktop#526

Open
SawyerHood wants to merge 9 commits into
mainfrom
sawyer/update-when-agents-finish
Open

Update when agents finish: deferred self-update for web/VPS and desktop#526
SawyerHood wants to merge 9 commits into
mainfrom
sawyer/update-when-agents-finish

Conversation

@SawyerHood

@SawyerHood SawyerHood commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Fixes #477

bb running unattended (VPS via npx bb-app, or the desktop app with an owned local runtime) can now apply updates without interrupting running agents: schedule the update from the toast, and it applies at the first moment no agents have been running for a 45s quiet period.

Web / VPS (npx bb-app)

  • The "bb-app update available" toast forks on live agent activity. At rest: a single Update now (the server skips the quiet period when provably at rest — no busy threads and no queued follow-ups awaiting the auto-send sweep). While agents work: Update when agents finish (defers) alongside Update now (mode now: applies straight after staging, interrupting agents at the user's explicit request; also escalates an already-waiting schedule) (when the server runs under a capable launcher; otherwise the current "restart bb-app" copy is unchanged). While scheduled, a persistent toast shows the target version with Cancel update; after the swap, a "bb is now running vX" toast confirms.
  • POST/DELETE /api/v1/system/update/schedule + selfUpdate state on /system/version. The intent lives server-side, so closing the browser tab doesn't lose it.
  • The server stages npm install bb-app@<latest> under <dataDir>/self-update/ while agents keep running (a staging failure surfaces as an error toast before anything restarts), writes a sentinel file, and — once threads.status IN ('starting','active','stopping') stays empty for 45s — exits with a reserved code (75).
  • The bb-app launcher, seeing that exit code plus a valid sentinel, stops the daemon and restarts both children from the staged package root (~5s downtime, verified e2e). The sentinel is consumed one-shot; if the staged version fails to become healthy, the launcher tears it down and restarts the current version.
  • Safety gating: the launcher advertises the protocol via BB_SELF_UPDATE_PROTOCOL=1 only in the supervised full-stack path — never for dev servers, standalone bb-server, old launchers, or desktop-owned runtimes (electron-updater owns those). A pending schedule survives server crashes (resumed from the sentinel on boot); stale staged installs are pruned on boot.

Desktop

  • "Desktop update ready" offers Relaunch when agents finish (primary) / Relaunch now only while agents are busy on a shell-owned runtime; at rest (or attached to a remote server) it keeps the plain Relaunch. Scheduled state shows as a toast with Cancel update.
  • The Electron main process polls the new GET /api/v1/system/agents/activity endpoint (same busy-thread definition as the server-side watcher, shared via agent-activity.ts) and defers quitAndInstall() until the quiet period passes. Unreachable server counts as busy; the deferral auto-cancels if the downloaded update goes away; quitting the app still installs on quit as before.
  • Contract changes are backward compatible: new BbDesktopInfo fields and API methods are optional, so a new SPA against an old shell degrades to today's behavior.

Verified

  • Live e2e of the full VPS loop: local build labeled 0.0.1 → schedule → staged install of the real published bb-app@0.0.27 → 45s quiet → launcher swap → back up as 0.0.27 in ~5s, sentinel consumed. The e2e also exercised the failed-swap rollback and boot-time staging cleanup, and caught two real bugs now fixed with regression tests: npm_execpath poisoned by pnpm, and symlinked staged paths (/tmp/private/tmp) silently killing the daemon's argv-gated entrypoint.
  • New tests: 9 self-update service tests (in-memory SQLite, injected installer/clock), 3 launcher supervision swap tests, 5 desktop deferred-install controller tests, agent-activity route test, npm-resolution unit test. Full repo typecheck passes.
  • Desktop deferred relaunch is unit-tested but not e2e'd (needs a packaged shell + update feed); the only untested seam is electron-updater's own quitAndInstall, which the existing Relaunch button already exercises.

🤖 Generated with Claude Code

Web/VPS (npx bb-app): the update toast gains "Update when agents finish".
The server stages an npm install of the latest bb-app under the data dir
while agents keep running, watches for zero busy threads sustained over a
45s quiet period, then exits with a reserved code; the launcher swaps both
children to the staged version (seconds of downtime) and falls back to the
current version if the staged one fails to start. Capability is gated by a
launcher-set env var, so dev servers, standalone bb-server, old launchers,
and desktop-owned runtimes never see the protocol.

Desktop: the "Desktop update ready" toast gains "Relaunch when agents
finish" when the shell owns the local runtime. The main process polls the
new /system/agents/activity endpoint and defers electron-updater's
quitAndInstall until agents have been idle for the same quiet period.

Fixes #477

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@SawyerHood
SawyerHood force-pushed the sawyer/update-when-agents-finish branch from df9fb69 to c2a1bc5 Compare July 7, 2026 20:57
SawyerHood and others added 8 commits July 7, 2026 14:14
The update toast now reads live agent activity: "Update now" when nothing
is running, "Update when agents finish" while agents work. Both go through
the same schedule endpoint; the server skips the 45s quiet period only
when it has seen no busy agents since arming AND nothing is queued for the
auto-send sweep to start (isServerAtRest), so a stale label can never kill
an agent. Desktop mirrors this: plain Relaunch at rest, the deferred
option only while agents are busy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The schedule endpoint gains an explicit mode: "when-idle" defers behind
the idle watcher (with the at-rest fast path), "now" applies as soon as
staging completes, interrupting running agents at the user's request.
While agents work the web toast offers both "Update when agents finish"
and "Update now"; re-posting "now" escalates an already-waiting schedule.
At rest the single "Update now" button keeps mode when-idle since the
fast path is already immediate but still backs off if an agent starts
during staging. Crash resume stays conservative (when-idle). Scheduling
from the toast no longer records a version dismissal, so cancelling
brings the offer back.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The busy/idle action label now reads the same push-updated
sidebar-navigation cache that renders the sidebar's running spinners,
instead of polling /system/agents/activity every 15s: the label flips
instantly and always agrees with what the sidebar shows. The bootstrap
excludes side-chats and archived threads, so an idle-looking label maps
to mode "when-idle", whose server-side at-rest gate counts every thread
before skipping the quiet period. The activity endpoint remains for the
desktop main-process deferred-relaunch poller, which has no SPA cache.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reverts the sidebar-cache derivation in favor of polling
/system/agents/activity every second while the update choice is on
screen. The endpoint is one indexed count on a small table (~1ms over
loopback) and the poll is gated to when an update is available, capable,
and not yet scheduled or dismissed — so the cost is negligible. Unlike
the sidebar bootstrap (which excludes side-chats and archived threads)
the endpoint counts every thread, so the busy/idle label is global truth
rather than a filtered view, while still flipping within a second.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
From an agent review of the PR for simplification opportunities:
- Flatten the launcher supervision loop: the inlined ~85-line swap branch
  becomes applySelfUpdateSwap + restartFromStack helpers, and the loop's
  duplicated per-process restart blocks collapse into one tail.
- Drop requestedAt from the client-facing contracts (nothing read it); it
  survives only in the on-disk sentinel as debugging provenance. The
  desktop deferredInstall object collapses to a boolean.
- Remove the illusory protocol "version" constant: the server parses the
  env var as a boolean, so the value never carried version information.
- Share the 45s quiet period as one constant in @bb/config so the server
  watcher and desktop controller cannot drift.
- Delete the dead parseSelfUpdateSentinel export, a redundant regex in
  resolveNpmInvocation, and the preload's duplicate invoke helper.
- Extract useAgentsBusy so the unknown-reads-as-busy default cannot drift
  between the web and desktop toasts.

No behavior changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The persistent update toasts were verbose and, once both button slots held
real actions, had no visible way to dismiss. AppToastContent gains an
opt-in corner close button (routes through the toast's normal dismissal,
so onDismiss persistence still applies). The update toasts drop their
redundant description lines — the version moves into the title — and the
deferred action reads "Update when idle" / "Relaunch when idle". X on a
scheduled toast only hides it; the update itself continues.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The tab that watched the update was left running the pre-update frontend
bundle. On seeing the post-swap version, reload the page and stash the
version in session storage so the fresh bundle announces "bb-app updated"
after the reload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Quiet period: queued follow-ups are now checked directly on every watcher
tick (server via countQueuedIdleThreads, desktop via a queuedThreadCount
field on /system/agents/activity) instead of insured against with time,
so the shared quiet period drops from 45s to 10s and polls from 15s to
5s. After the last agent finishes, a deferred update now applies in
~10-15s. Queued work also reads as busy for the toast label.

Blank page: reloading into the updated app could race the restart — a
failed module fetch never retries, stranding a blank page (reproduced in
a real browser). The reload now waits until the server serves the app
shell, and index.html gains a one-shot boot watchdog that reloads if the
app has not mounted after 10s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@SawyerHood

Copy link
Copy Markdown
Collaborator Author

Handoff notes (state as of 350c1ffdd)

Everything through the quiet-period cut and blank-page mitigations is committed and pushed. One investigation is unresolved:

Open: blank page after the post-update auto-reload

Reproduced twice in a real browser (Playwright against the tarball e2e setup: local build labeled 0.0.1 updating to the published 0.0.27):

  • After the swap, the tab auto-reloads and lands blank: fresh index.html served (200), the module asset 200s when re-fetched later, but #root never mounts and module scripts don't retry — stuck until a manual reload, which then works every time.
  • Best evidence from the instrumented run: the page went mounted=true → mounted=false ~30s after the swap with only one load event recorded — i.e. the teardown/navigation began but the new document never finished loading. No console errors, no ≥400 responses captured at that moment.
  • Two mitigations landed in eedbda554/350c1ffdd but did not fix the e2e repro: a pre-reload probe (fetch("/") until ok) and a one-shot boot watchdog in index.html (reload if #root empty after 10s). Important caveat: in the e2e the post-swap frontend is the published 0.0.27, which doesn't contain the watchdog — so the watchdog is untestable in this setup and may well be sufficient for real forward updates. The published-server logs also show listDueAutomations schema errors (old code on the branch's newer DB — a downgrade artifact of the test trick, not a real-update condition; possibly related to the blank load).

Next steps when picking this up: re-run the browser repro with the same build on both sides (e.g. two locally-built tarballs 0.0.1→0.0.2 via a local registry or BB_UPDATE_REGISTRY_URL-style override) to remove the downgrade artifacts; if it still reproduces, capture the network waterfall of the failing document load itself.

Repro harness: build + pack bb-app, npm install the tarball to a prefix, set its package.json version to 0.0.1, run with BB_DATA_DIR=/tmp/... BB_SERVER_PORT=4990 BB_HOST_DAEMON_PORT=4991, click "Update now" in the toast.

Everything else is verified

Server 995/995, launcher 46/46, app 1282/1282, desktop controller 5/5, typecheck 40/40. The swap itself (staging → exit 75 → launcher swap → rollback-on-failure → crash resume) is e2e-verified in the terminal; the blank page only concerns the browser tab's landing after completion.

🤖 Generated with Claude Code

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.

Update feature should have 'update when agents are finished'

1 participant