Repository navigation
Auto-update: server updates itself when quiet - #5
Conversation
|
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
|
|
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). |
|
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) |
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 |
A boot-service server checks its release track hourly and, when the automaticUpdates setting is on, runs the existing self-update once no thread, background task, terminal command or quiet command objects. A WorkAdmission gate holds new work from the last check to the restart, and the restarted server starts the held work once the launcher commits. This squashes the reviewed rounds 1-3 (9df6fa4..350656a) and rebases them on fork main. Round 4 changes: - Settings take upstream pingdotgg#17585/pingdotgg#17586's name and place: automaticUpdates (was autoUpdateServer), plus the fork-only automaticUpdatesQuietCommand (was autoUpdateQuietCommand). A file with the old names is renamed in memory on load (cloud/autoUpdateSettings.ts); no schema migration. - The desktop install-on-quit half moved out of this PR; it waits for upstream pingdotgg#17588. - Rebase fixes for fork main: worker.runOnce(claimLimits), launcher.prepareTrial({ baseDir }), the launch layer's event sink. Co-Authored-By: Claude Opus 5.5 <[email protected]>
350656a to
3ecd968
Compare
|
lens: data-integrity DI-2 r4 · reviewer: opus high (data-integrity DI-2 r4) · read at: 3ecd968 covered: the round-4 change at the head. (1) The rebase conflict merges: checks:
finding: minor apps/server/src/serverSettings.ts:798 The order "rename, then verdict: SAFE |
|
lens: data-integrity DI-1 r4 · reviewer: opus high (t3lens5-di1-r4) · read at: 3ecd968
|
Why
A server that runs as the background service only updates when someone presses Update. Left alone it falls behind. This adds an opt-in updater that installs a newer release by itself, but only when nothing is working, and holds any work that arrives during the restart so it starts afterwards instead of being lost.
Upstream has the same idea in review: pingdotgg#17585 adds the
automaticUpdatessetting and pingdotgg#17586 uses it for server self-update. This PR now uses the same setting name, type, schema shape and place in the file, so when upstream merges, their line replaces ours and saved choices carry over.What changed in round 4
EffectWorker.ts(the gate now wrapsworker.runOnce(claimLimits)),serverRuntimeStartup.ts(main's import shape kept;startHeldWorkstill right afterprepareTrial), andThreadLaunchService.test.ts(both sides' tests kept). Test fakes updated for main's newrunOnce/nextClaimableAt/protocolshapes.autoUpdateServer→automaticUpdates(Schema.Boolean.pipe(Schema.withDecodingDefault(...)), right afterenableProviderUpdateChecks, as in feat(server): install provider CLI updates in the background pingdotgg/t3code#17585);autoUpdateQuietCommand→automaticUpdatesQuietCommand(fork-only; upstream has no quiet hook).apps/server/src/cloud/autoUpdateSettings.tscopies old keys to the new names in memory, just before the settings decode (one call inserverSettings.ts). The next settings write saves only the new names. See "Settings rename" below.What
One opt-in server setting, off by default:
automaticUpdates(with optionalautomaticUpdatesQuietCommand).There is no UI toggle yet.
SettingsPanels.tsxchanges often upstream, and editing it would conflict on almost every sync; upstream's pingdotgg#17585 adds the toggle under the same name. Users set the option in the server settings file.Settings rename: old file, empty file, torn file
renameRetiredAutoUpdateKeysruns on the parsed file beforePersistedServerSettingsdecodes it. Unknown keys are ignored by the decode, and writes are sparse (defaults are not saved), so:autoUpdateSettings.test.ts)autoUpdateServer: true,autoUpdateQuietCommandautomaticUpdates/automaticUpdatesQuietCommandsaved; old keys gone"yes",7){}{ "autoUpdateServer": tr)null, array, number)autoUpdateServerautomaticUpdatesand reads off: fails closed (no update). The launcher does not restoresettings.json.Settings writes are atomic (
writeFileStringAtomically), so a half-written write leaves the old file whole.Default: off here, on upstream. The fork keeps this opt-in, as this PR always said. Upstream pingdotgg#17585 defaults
automaticUpdatesto true. Because a value equal to the default is not saved, a server that never turned it on has no key in its file. When upstream's line replaces ours, such servers will follow upstream's default and start updating by themselves (provider CLI updates too, which upstream ties to the same setting). Servers that turned it on stay on. Whether the fork should keep "off" after convergence (by overriding the default) is an owner decision for that sync, not this PR.Rejected alternatives
PersistedServerSettings(a schema transform with both keys): changes upstream's contract file in more places, so it would conflict when feat(server): install provider CLI updates in the background pingdotgg/t3code#17585 lands. The rename lives in a fork-owned file with a single call site, to delete at convergence.RestartWork) around clones, worktree setup, review guides and terminals. It does not gate the outbox's turn starts and keeps nothing across the restart, so a message that arrives during the handoff takes upstream's normal recovery path (user messages wait in a held queue, agent messages are cancelled). It has no quiet hook. This PR's outbox gate and held-work marker are the parts upstream does not have; when feat(server): background services update themselves pingdotgg/t3code#17586 lands, the fork should switch to it and keep only those parts if they are still needed.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;automaticUpdatesQuietCommandis 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.Tests
All runs with
GH_TOKEN/GITHUB_TOKENunset and an emptyGH_CONFIG_DIR, on 3ecd968:autoUpdate,autoUpdateSettings,workAdmission,serverRuntimeStartup.updateHold,ProviderRuntimeRecoveryService,ThreadLaunchService,runtimeLayer,selfUpdate): all pass (320/320 after the rebase, thenautoUpdateSettings+autoUpdate50/50 after the rename).What the test files cover:
autoUpdateSettings.test.ts(7, new): the settings rename table above.autoUpdate.test.ts: a fake-server loop around the real updater (launcher, thread state, hook, self-update, gate): hook → gate → thread state order; marker written and cleared; hold until exit; release on a rejected handoff; lost answers, grace and the 30-minute cap; in-progress reset; refusal on a pass-through or partly registered gate; terminal commands; round-1 cases.workAdmission.test.ts: hold and release, in-flight count, pass-through default, outbox not claiming while held, marker freshness, consumer registration.ThreadLaunchService.test.ts: a worktree preparation waits at a closed gate, then runs; one held at stop stayspreparingand the next process resumes it.ProviderRuntimeRecoveryService.test.ts, "work held for an automatic update": starts only afterstartHeldWork; request-time independence; user-held queues untouched; rollback and crash give exactly one start; recovery twice; part-way failure and retry;runsnarrowing; none/stale/invalid markers.serverRuntimeStartup.updateHold.test.ts: held work starts afterprepareTrial, and not when it fails.runtimeLayer.test.ts: simulated restarts through the real runtime layer, and end to end on a file database with commit and with rollback: each held run reaches a provider exactly once.Guards, mutation-checked. Round 4, each a hand break on the new head with only its own tests run:
serverSettings.tsadmittedguard removed (:953)queueHeldguard removed (ProviderRuntimeRecoveryService.ts:379)startHeldWorkremoved from startupsetPreparationResumerremoved (:1301)startHeldWorkmoved beforeprepareTrialEarlier rounds' mutation tables (round 1 and round 3) are in the PR history; the round-3 ones above were re-run after the rebase because their lines moved.
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.runRecoveryOncehas no production caller, and noProviderTurnStartServicecaller bypasses the outbox).General vs project
All general. One server setting and its gate in the public fork; no project names, paths, hosts or ids. The quiet command is the hook for project-specific rules (a project points it at its own script); this PR ships none.
🤖 Generated with Claude Code