Repository navigation
Auto-update: server updates itself when quiet, desktop installs on quit - #5
scuttletoby wants to merge 4 commits into
Conversation
Opt-in settings, both off by default: - Server `autoUpdateServer`: in boot-service mode, check the release source hourly; when a newer release on the same track exists, wait until no turn, approval, link request or background task is live (and an optional `autoUpdateQuietCommand` exits 0), then run the existing self-update. Skips the version the launcher rolled back; gives up a wait after 2 hours and never forces. - Desktop `autoUpdate`: download an available update after each check and install it when the app quits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
lens: correctness+concurrency DI-1 · reviewer: opus high (t3lens-di1-opus) · read at: 9df6fa4
|
|
lens: correctness+concurrency DI-2 · reviewer: opus high (t3lens-di2) · read at: 9df6fa4
|
…ty, desktop record Server: - WorkAdmission gate in front of outbox claims and worktree preparation. The last check at the self-update's installing stage reads the setting, runs the quiet hook, closes the gate, counts admitted work, then reads thread state. The gate stays closed until exit; it opens again on a rejected handoff or when the launcher drops the pending record. - Activity comes from upstream's runtime recovery projections and derivePendingBackgroundWork, so every provider's background work counts, plus unheld queued runs and running terminal commands. - A failed launcher record backs off once instead of skipping forever; an unreadable state file stops the update with a warning; the Update button's update running first retries after a minute. - makeAutoUpdater takes its dependencies, and the loop is tested against a fake server. Desktop: - Arming install-on-quit writes the install record, so an install that did not apply is reported; that version is not armed again automatically. - Install-on-quit is switched off when the setting is off or the track changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
fix round 1: every major from DI-1 and DI-2 is fixed at 5280dbc (was 9df6fa4). Each line below is finding → fix → test. Each named test fails when its guard is removed; the evidence is at the end. Majors
Minors
Still open, with reasons
Guard removal (each removed alone, then restored) At least one test goes red for every guard:
Red on 9df6fa4:
Local runs at 5280dbc (fork CI does not run tests):
Unproven:
|
|
lens: correctness+concurrency DI-1 r2 · reviewer: opus high (t3lens-5-di1-r2) · read at: 5280dbc covered: fix diff 9df6fa4..5280dbc, all 10 files; non-test code line by line: workAdmission.ts, autoUpdate.ts (whole file), EffectWorker/ThreadLaunchService/server.ts hunks, DesktopUpdates.ts. Read what it calls into: EffectWorker.runOnce/drain/runRecoveryOnce and their callers, ThreadLaunchService.schedulePreparation/prepareRun, selfUpdate.update (stage callbacks, inFlight), serviceLauncherClient.exchange (30 s timeout), serviceLauncher #handleUpdateRequest/#beginTrial/startOnlineDatabaseBackup/writeServiceState, launcherWatch, ProviderRuntimeRecoveryService.reconcileProjection, RestartContinuation.restartContinuationRun, EffectOutbox process-loss sets, ProjectionStore getRecoveryThreadIds("runtime"), IdleCompactionService, UsageLimitRecoveryWorker, ScheduledTaskService.runTask, the desktop install-record path (updateMachine.resolveNotAppliedInstallVersion). Tests: workAdmission.test.ts, autoUpdate.test.ts, DesktopAutoInstall.test.ts, read for what they prove. checks:
finding: minor apps/server/src/cloud/autoUpdate.test.ts:406 (guard: autoUpdate.ts:354-358). This ordering is the restart-gap fix: thread state is read after gate.hold. Moving the finding: minor apps/server/src/workAdmission.ts:1-3 + autoUpdate.ts:3-4 vs ProviderRuntimeRecoveryService.ts:331-443. "Held work waits in the database for startup recovery" is not what happens. A run held at the gate sits at status starting with startedAt null. At restart, user messages become a held queue that the user has to release. Every createdBy "agent" message is cancelled: MCP t3_thread_send, delegate follow-ups, project/worktree MCP sends, scheduled tasks, delegated completions, notifications. Only usage-limit resumes are re-sent. This is worse than "still open" states in two ways: (a) it covers every agent-created message, not just the scheduled-task/notification/delegated-completion kinds; (b) on a non-reflink filesystem (ext4 here) the window lasts as long as the launcher's online DB copy (serviceLauncher.ts:304-312), which can be minutes for a large DB. Still no kill part-way, and better than upstream's Update button (which kills mid-turn), hence minor. At least fix the two comments, and say in the PR that messages sent during an update are held or cancelled. (high) finding: minor apps/server/src/cloud/autoUpdate.ts:386 + 462-471. Gate releases do not follow what the launcher actually does. (a) watchHandoff treats an "unreadable" record (a transient read error) as "dropped", reopens the gate and backs off 6 h, while the launcher's pending update still SIGTERMs the server. (b) requestUpdate's 30 s IPC timeout (serviceLauncherClient.ts:191) -> onError release + "failed" 6 h back-off, but the launcher can still write pending and restart after a late accept. Admitted work is then killed. (c) After a real drop, selfUpdate's inFlight is reset only onError (selfUpdate.ts:333). So every later auto attempt, and the Update button, gets "already in progress" until the process restarts. (d) A pending record that never resolves (launcher hung in the copy) keeps every message waiting, with no limit and nothing in the UI. Suggest: keep holding on "unreadable" or a timeout until the record shows a terminal status, cap the hold with a logged release, and reset inFlight when watchHandoff releases. Each case is unlikely. (medium) finding: minor apps/server/src/orchestration-v2/ThreadLaunchService.ts:823. No test covers the gate on worktree/setup preparation (the author acknowledges this; I did not re-run that mutation). Nothing tests that the production layer graph gives EffectWorker and ThreadLaunchService the same enforced gate as autoUpdate. WorkAdmission is a Context.Reference that defaults to pass-through, and autoUpdate checks finding: minor apps/desktop/src/updates/DesktopUpdates.ts:930 + 62. The install record is written when install-on-quit is armed, not when an install starts. So any restart of the old version while armed counts as "did not apply": crash, force-quit or kill, power loss, an OS reboot that skips electron-updater's quit hook. The UI then reports a failed install that was never tried, and that release is left to the Update button until a newer one ships. That is broader than "crash" in "still open". Also, disarm runs only at the 4-minute poll, so turning the setting off and quitting within 4 minutes still installs. (high) |
|
lens: correctness+concurrency DI-2 r2 · reviewer: opus high (t3lens-5-di2-r2) · read at: 5280dbc covered: the whole fix diff 9df6fa4..5280dbc. I read every non-test line in workAdmission.ts, cloud/autoUpdate.ts, cloud/selfUpdate.ts, the server.ts wiring and desktop updates/DesktopUpdates.ts. I also read the code they call into: serviceLauncherClient.ts (30 s exchange timeout), serviceLauncher.ts (HANDOFF_DELAY, #recover, pending record), ProviderRuntimeRecoveryService (startup and shutdown reconcile), ThreadLaunchService, EffectWorker.runDaemon and serverRuntimeStartup. Tests: at head, server autoUpdate + workAdmission 38/38 pass and desktop updates 98/98 pass. At base 9df6fa4 with the new tests, 30 server and 4 desktop tests fail, so they go red to green. Hand mutations killed by the tests: gate.hold swapped for inFlight (3 red); the onError release removed; the watchHandoff release removed; the daemon admit removed; the background-work count removed; desktop guards D1-D5 each broken. Mutations that survived: M4 (read thread state before gate.hold), M7 (ThreadLaunchService admit removed, 84 tests still pass), G1 (terminal count forced to 0), G2 (no latch close in hold: correct but spins), M5 (no release-when-busy: harmless, because onError releases anyway). Wiring: Layer.provide(Layer.merge(layerActivation, layerWorkAdmission)) in server.ts covers the routes (ServerAutoUpdate), the runtime services (orchestration, ThreadLaunchService, the EffectWorker daemon forked from serverRuntimeStartup) and McpSessionRegistry. Nothing that admits work sits in the layers below it. I checked this by reading only, not at runtime. WorkAdmission is a Context.Reference with a pass-through default, so a layer that misses the provide fails open silently. Round-1 majors: restart gap fixed (hold, then last activity read, then handoff; the gate stays closed until exit); non-Claude background work now counted; guards tested; desktop failed install-on-quit now reported, not looped; install-on-quit now disarmed. Round-1 minors: the Update-button race is fixed by the inFlight getAndSet; a transient failure now leads to afterFailure and a retry; an unreadable state file stops with one warning; terminal commands are counted. checks: state question. Rerun: decideRelease plus the launcher record make a second check idempotent; a pending record for the same version keeps the hold, anything else releases. Stops half-way: if the process crashes after the gate closes, the new process starts with a fresh, open gate; the launcher's #recover resumes or finishes the pending update; held work is still in the DB, but see finding 1 for what recovery does with it. Legacy or unreadable state file: stops and warns once, with no loop. Empty history: launcher record NotFound is read as none, which leads to release and afterFailure. Update applied but process not restarted: watchHandoff sees a record that is no longer pending and releases, with a warning. Two servers on one DB: out of scope for this PR (each process has its own gate, so the gate does not protect the other one). Gate stuck closed forever: launcher rejection, client error and hook/activity failure all release through onError; a launcher drop is released by watchHandoff. The one unbounded case is a launcher that wedges with the record left pending (finding 4). Hardest case: work that arrives during the hold window. It cannot start, which is correct. What happens to it afterwards is worse than the comments say (finding 1). |
…desktop record at quit Held work: - The WorkAdmission gate writes a marker (runtime/work-held-for-update.json) when an update holds new work. Startup recovery reads a fresh marker and starts runs and worktree preparations requested since then, with queued runs behind them, instead of cancelling them or leaving them in the held queue. Without a marker upstream recovery is unchanged. - Outbox, thread launch and recovery register on the gate; the updater refuses loudly on the pass-through gate or when a consumer is missing. A launch waiting at a closed gate no longer fails the run. Gate vs launcher: - The gate closes before the last thread-state read. After the launcher was asked, a lost answer or an unreadable record keeps the hold; it opens only when the record shows no restart (after a grace period for an unconfirmed handoff) or after 30 minutes, with a warning, and the self-update's in-progress flag is reset. Desktop: - The install record is written when the app quits, not when install-on-quit is armed, and the autoUpdate setting is read again at quit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
fix round 2: 5280dbc..5164fea answers DI-1 (6823167624) and DI-2 (6823190929). The PR body is updated; it now includes the window estimate. Held work (DI-1 finding 2, DI-2 finding 1). Work held at the gate now starts after the restart. It is not cancelled and does not land in a held queue.
Gate vs launcher (DI-1 finding 3; DI-2 findings 2, 3, 4).
Tests (DI-1 findings 1 and 4, DI-2 finding 5). These were the surviving mutations:
Wiring:
Held-work tests in Desktop (DI-1 finding 5, DI-2 finding 6).
Red / green.
Still unproven.
|
|
lens: data-integrity DI-1 r3 · reviewer: opus high (t3lens-5-di1-r3) · read at: 5164fea
|
|
lens: data-integrity DI-2 r3 · reviewer: opus high (t3lens-5-di2-r3) · read at: 5164fea covered: fix diff 5280dbc..5164fea in full (workAdmission.ts, cloud/autoUpdate.ts, cloud/selfUpdate.ts, ProviderRuntimeRecoveryService.ts, ThreadLaunchService.ts, EffectWorker.ts outbox gate, server.ts / serverRuntimeStartup.ts wiring, desktop install-on-quit finalizer) plus what it calls: launcher prepareTrial / handoff record, shutdown reconcile, restartContinuationRun, recoverDelegatedTasks, Orchestrator dispatch (requestedAt), electron-updater quit path. Every round-2 point from 6823167624 and 6823190929 is fixed at the root: held work is no longer cancelled and needs no manual release (marker file + start after restart, outbox claims nothing while held); only an explicit launcher reject releases the gate, a lost answer becomes handed-off with a 5-minute grace, 30-minute cap; outbox, thread launch and recovery register on the gate and the updater refuses on a missing consumer; desktop record is written at actual quit and autoUpdate is re-read there. checks:
finding: minor apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts:1046 (with serverRuntimeStartup.ts:640) held turns are started during startup recovery before the trial is committed; trial fails after that -> launcher restores the DB, marker kept, old version starts the same turns again -> each held turn runs twice and the first run's file edits stay. Suggested fix: start held work only after prepareTrial commits (or only when no trial is pending). (high) |
- Startup recovery only keeps work held for an update; startup starts it
after launcher.prepareTrial succeeds, so a rolled-back trial starts nothing.
- Held work is chosen by save order, not request time: everything unclaimed
at the restart was saved after the hold ("send now", resume and retry keep
their old request time).
- The marker records the runs kept by the first startup and the runs started,
so later startups (after a rollback, a crash or a part-way failure) start
only what is left. A failure keeps the marker.
- The updater waits for held work to settle before checking again, and keeps
the hold past the 30-minute cap while the launcher's record is pending.
- Tests: end-to-end rollback on a file database, startup order, each guard.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fix round 3Fixes the round-3 findings (DI-1 #issuecomment-6825354751, DI-2 #issuecomment-6825370028). Range:
The PR body has the state table (one row per write step: crash right after it, second run, old or empty state, with a test each), the hand-break table and the local runs. Server: 2512/2513 + 4726 pass; the one failure is Not proven:
🤖 Generated with Claude Code |
What
There are two opt-in settings, and both are off by default.
autoUpdateServer(in the server settings, with optionalautoUpdateQuietCommand)"autoUpdate": true(indesktop-settings.json)There are no UI toggles yet.
SettingsPanels.tsxchanges often upstream, and editing it would cause a rebase conflict on almost every sync. Users set the options in the settings files.How the server decides it is quiet
Thread state is read through upstream's own helpers: the threads from
getRecoveryThreadIds("runtime"), each read withgetRuntimeRecoveryProjection. The server is quiet only when all of these hold:derivePendingBackgroundWork. That covers provider background tasks and livecommand_execution/dynamic_tool/subagentturn items, so Codex and the other adapters count too, not only Claude;autoUpdateQuietCommandis set, it exits 0.Link requests are not counted. Recovery leaves link cards in place across a restart, and one the user ignores would otherwise block updates forever.
The quiet command is an absolute path. It runs with the server base dir as its cwd and a 30 s timeout, and gets two environment variables:
T3CODE_AUTO_UPDATE_CURRENT_VERSIONandT3CODE_AUTO_UPDATE_TARGET_VERSION.The command runs twice per attempt: once at each quiet check, and once more at the handoff.
Work that arrives during the update is held, then started
A server-wide gate,
WorkAdmission(apps/server/src/workAdmission.ts), sits in front of the places where new work starts:EffectWorker.runDaemonWithOptions): turn starts, continues, steers, approval answers and notifications;ThreadLaunchService), including setup scripts.At the self-update's
installingstage, just before the launcher request, the updater runs the last check in this order:<baseDir>/runtime/work-held-for-update.jsonwith that time and the target version. If it cannot be written, the update does not go ahead.If any step says "not now", the gate opens again (removing the marker) and the updater goes back to waiting. The staged runtime is reused next time.
While the gate is closed, nothing new is claimed. Outbox rows stay pending in the database. A worktree preparation waits at the gate instead of failing its run.
At the restart: decided by save order, started after the commit. Startup recovery (
ProviderRuntimeRecoveryService) reads the marker. If it is fresh (under 2 h old), recovery keeps the held work instead of cancelling it. Held work means every start, preparation or queued run that no provider ever claimed.The marker's time is not used to pick this work. The gate closed before the last quiet check, and anything that check counted stopped the update. So all unclaimed work at the restart was saved after the hold, whatever its request time. That also covers "send now", a resumed queue and a worktree retry, which keep their old request times, and a request stamped just before the hold but saved after it.
t3_thread_sendmessage, a delegated child's completion report) gets a freshprovider-turn.start;preparinghas its worktree preparation run again;User-held queues and buffered notifications keep upstream's behaviour. Without a marker, recovery is unchanged.
Nothing starts before the launcher commits the update. Recovery only decides what to keep. Startup (
serverRuntimeStartup.ts) starts it withstartHeldWorkright afterlauncher.prepareTrialsucceeds. If the trial fails before the commit, the launcher restores the database and the previous version runs again. That version finds the same held runs and starts them, so each starts once across both processes.Exactly once across restarts. The marker records two things:
runs), written before the commit;started), written per thread before that thread's starts are written.Each later startup considers only
runsminusstarted. Once everything has started, the marker is removed. If starting fails part-way, the marker stays, and the next startup starts only what is left. A crash between thestartedrecord and the start write loses that start (at most once, not twice). The run then takes the upstream path at the next startup, as a visible held queue or a cancelled agent message.The updater does not check for updates again until held work has settled. A stale (over 2 h) or unreadable marker is removed and ignored.
Outbox, thread launch and recovery each register on the gate. The updater refuses to run, with a logged error, on the pass-through default (tests and tools) or when one of them did not register. It also refuses unless the server is the background service.
Gate vs launcher
Once the launcher has been asked, the gate stays closed until the process exits, with these exceptions:
AUTO_UPDATE_MAX_HOLD_MS) the gate opens, unless the launcher's record still shows this update pending. A pending record keeps the hold past the cap, because the restart may still come, and a warning is logged once. An unreadable or different record past the cap opens the gate.When a handoff is dropped, the gate opens (removing the marker), the self-update's "update in progress" flag is reset so the Update button works again, a warning names the reason, and the updater backs off.
Visible status. The log shows when the hold starts (with the target version and the 30-minute limit) and why it ended. There is no UI or health surface for the auto-updater to show it in, and this PR does not add one.
How long the window is. It runs from the gate closing until the new server's recovery starts the held work. On a filesystem without cloning (ext4, for example) the launcher goes through these steps in order:
HANDOFF_DELAY_MS(2 s);cpand fsyncs it, while the old server still runs;A synthetic 1 GiB file copied and fsynced on a SATA ext4 disk took 1.4 s, 5.8 s and 5.1 s in three runs, with the source in the page cache. A cold source would be slower. A 128 MiB WAL took about 5 s, while earlier writes were still flushing. For a 1 GiB database that adds up to roughly 10–20 s plus the trial startup. The copy time grows about linearly with database size. Trial startup was not measured. On a filesystem that can clone (btrfs, XFS with reflink, APFS), the offline backup clones instead and the copy step is close to instant.
Reused code
Nothing new touches the install, handoff or rollback paths.
ServerSelfUpdate.update(apps/server/src/cloud/selfUpdate.ts) stages the runtime and hands off to the launcher. The auto-updater shares that one service instance with the Update button, so itsinFlightguard covers both. The last check runs inside itsinstallingprogress callback. The only addition isresetAfterDroppedHandoff.resolveNewestVersion/resolveMirrorVersionfromcli/update.ts, and honours a managed update mirror.runtime/service-state.json. See the failure table.RestartContinuation.tscontinues threads that were mid-turn, and its chain cap (MAX_CONTINUATION_CHAIN = 3) still applies.Failure cases
inFlightguard refuses before anything starts. The updater logs that another update is running and tries again a minute later. This is not counted as a failure.installingstage catches it. The update stops before the launcher handoff, the gate opens and the updater goes back to waiting.service-state.jsonunreadable after the handoff#recoverpath handle this, the same as for a manual update. A marker left by a crashed process is ignored once it is over 2 h old.failed(for example a transient start timeout)service-state.jsoncannot be read or parsed before an updateTimings. The first check is 5 minutes after start, then one check every hour. While waiting, quiet is checked once a minute.
State: each write step, run again, stopped half-way, old or empty state
Each row is one write, with what happens if the process crashes right after it, if the step runs a second time, and if it meets old or empty state. Test names are in
ProviderRuntimeRecoveryService.test.ts(PRRS),autoUpdate.test.ts(AU),workAdmission.test.ts(WA),serverRuntimeStartup.updateHold.test.ts(SRS) andruntimeLayer.test.ts(RL).holdtwice stays held, and one release opens it.heldAt, target)runslist that is already recorded.runs, then keeps the work in memoryruns, and keeps the same work.runsis recorded only once, so a later startup cannot widen it.runs(written by the first startup only), everything unclaimed counts.prepareTrialsucceeds; per thread,startedis written, then its startsstarted: the next startup starts it (once). Betweenstartedand the start write: that start is lost (at most once) and the run takes the upstream path. After: nothing more starts.startHeldWorkor a later startup starts onlyrunsminusstarted. A part-way failure keeps the marker, and the retry finishes the rest.started: the start is not written, the marker stays, and the updater waits (settled stays false) until the next startup.runsandstarted: it would start held work by its own rule. Only the 2 h freshness limit protects against that.Desktop
autoDownloadAvailableUpdateruns after the startup check and after each poll check.setAutoInstallOnAppQuit(true).How quitting interacts with it:
app.quit, so electron-updater's install-on-quit runs.exit(0), which skips the install.Tests
apps/server/src/cloud/autoUpdate.test.tsruns a fake-server loop around the real updater: launcher, thread state, hook, self-update and gate. It covers:runningTerminalCommands;apps/server/src/workAdmission.test.ts: hold and release, the in-flight count, the pass-through default, the outbox daemon not claiming while held, the marker file and its freshness, and consumer registration. Together withautoUpdate.test.ts: 47 tests.ThreadLaunchService.test.ts: a worktree preparation waits at a closed gate, then runs. A preparation still held when the server stops stayspreparing, and the next process'sresumePreparationprepares it.ProviderRuntimeRecoveryService.test.ts, block "work held for an automatic update":startHeldWork(the commit), and none starts during recovery;runslist narrows later startups;serverRuntimeStartup.updateHold.test.ts(fork-owned): held work starts afterprepareTrial, and not at all when it fails.runtimeLayer.test.ts:apps/desktop/src/updates/DesktopAutoInstall.test.ts(7): the record is written at quit and not before; a stop without a quit leaves no record; turning the setting off just before quitting installs nothing; plus the round-1 cases.Red on the old code. The new tests run against 5280dbc's sources: 35 server tests and 3 desktop tests fail. Some of the server failures are API changes. The hand mutations below are the precise check. Each was made on the new head, with only its own tests run, and every one turned at least one test red:
gate.holdadmitremoved from ThreadLaunchServiceadmitremoved from the outbox daemonRound 3 (fix round on 5164fea), each hand break on 350656a, with the tests it turns red:
startednot recordedrunslist ignoredstartHeldWorkmoved beforeprepareTrialstartHeldWorkremoved from startup:829guard)setPreparationResumerremoved (:1177)Local runs on 350656a (fork CI does not run tests):
src/orchestration-v22512/2513 pass; everything else 4726 pass (366 files). The one failure isProjectionStore.test.ts, which this PR does not touch: it usesStatementSync.stat, which this machine's Node lacks.browser-secret-nativeneeds thelibsecret-1dev package, which this machine lacks, andDesktopBackendManagertimed out under load during the full run but passes alone (12/12).Not yet proven:
runsandstarted. If a rollback goes back to such a version, it can start held work by its own rule; only the 2 h marker limit protects against that.startedleaves the updater waiting (held work not settled) until the next restart.startedrecord and the start write loses that start (at most once), and the run then takes the upstream path.recoverDelegatedTaskstreats a child that is stillpreparingat the restart was not checked.🤖 Generated with Claude Code