fix(server): make remote updates rollback-safe#5181

merged13
main
t3code/simplify-remote-updates
openedAug 1, 2026byTtheo
View on GitHubTwo-way sync enabledPR last imported 21d ago

Remote server updates had accumulated multiple version owners and handoff paths, so a failed candidate could leave the service pinned to a broken version.

This replaces that implementation with one stable systemd launcher that owns version selection and durable update state. Candidates are staged and checked against the exact migration manifest, complete all fallible startup before reporting prepared, then activate only after the launcher commits. Clients correlate reconnects with the launcher-generated update ID, while foreground servers keep the explicit manual-update path.

Verification

  • 281 focused tests across 16 files
  • Server, client-runtime, and contracts typechecks
  • Targeted lint and formatting
  • Production server bundle
  • Standalone launcher dependency check
  • Missing-database preflight check

Built with GPT-5.6-sol in the Codex harness via T3 Code.


Note

High Risk Changes core boot/update infrastructure: systemd units, version pinning, DB migration gating, and process handoff—failures can brick startup or leave the service on a bad runtime until rollback.

Overview Replaces remote self-update paths that pointed systemd at a versioned bin.mjs (and foreground respawn) with a stable service-launcher under ~/.t3/runtime, durable service-state.json, and IPC between the running server and the launcher.

Boot service now installs the launcher artifact, writes initial state, and sets ExecStart to node …/service-launcher.mjs (with KillMode=control-group). Pinned runtimes are npm-installed into a staging tree, validated (including __service-preflight against the live DB migration manifest), then atomically published; blocked preflights surface the local npx t3@… service update path instead of mutating the DB remotely.

Remote update RPC stages the target version, runs preflight on the staged entry, then requestUpdate on the launcher and returns a launcher-generated updateId for client correlation—no direct unit rewrite or detached respawn from the server process.

Startup ordering adds ServerActivation / forkParked so background reactors and side effects (runtime state, Tailscale, cloud link reconcile) wait until activation; HTTP ingress is parked behind awaitCommandReady. Launcher trial children skip WAL and migrations so preflight stays read-only.

Servers without an installed launcher-managed service can no longer self-update remotely; the error directs users to t3 service install.

Reviewed by Cursor Bugbot for commit ff26b5a2202bbdb58d2842f2d8e368f09621ceb1. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Make remote server updates rollback-safe via a stable systemd launcher and staged installs

  • Introduces a new serviceLauncher.ts process that owns update state and child process lifecycle. The systemd unit now points to a stable launcher file instead of a versioned CLI entry, so rollbacks don't require rewriting the unit.
  • pinnedRuntime.ts now stages npm installs into a temp directory, validates via a preflight check, writes a sentinel, then atomically publishes via rename. Staging is cleaned up on failure or interruption.
  • A new __service-preflight CLI command checks that DB migrations and launcher protocol match before an update is committed; if migrations are required, the update is blocked with a message to run npx t3@<version> service update locally.
  • The client now correlates the post-restart ready lifecycle event using a launcher-provided updateId and immediately surfaces a ServerUpdateTerminalError on rollback/failure instead of waiting for a generic reconnect timeout (extended to 4 minutes).
  • Introduces a forkParked/ServerActivation pattern so background fibers (tailscale, cloud link reconcile, runtime state persistence) are held until the activation gate is signaled, preventing races during startup.
  • Risk: removePinnedRuntimeInstallation is removed as a public API; callers that relied on it will break at compile time.

Macroscope summarized ff26b5a.

Added 1 commit

  • fix(server): make remote updates rollback-safed408091
apps/server/src/serviceLauncher.tsline 369Outdated · d408091Open thread
……@@ -0,0 +1,659 @@
366+ this.#rejectCompletion(error);
367+ }
368+
369+ async stop(signal: NodeJS.Signals): Promise<void> {
MmacroscopeappBoton line 369Aug 1, 2026

🟠 High src/serviceLauncher.ts:369

stop() sends the requested signal to the child and then waits indefinitely for it to exit via waitForExit, with no grace timeout or SIGKILL fallback. A child that handles or ignores SIGTERM/SIGINT blocks #completion from resolving, so the launcher hangs during shutdown until an external timeout kills the process tree. terminateChild() already implements a grace period followed by SIGKILL — stop() should use the same strategy so shutdown always completes.

✅ Resolved in 32ae738ae53a35a89fb8caf9ad9982a95512b3da

[GPT-5.6-SOL] RESPONDING ON BEHALF OF THEO

Fixed in 32ae738ae. stop() now runs through the serialized transition queue and uses the shared terminate helper, which preserves the requested signal and escalates to SIGKILL after the grace period.

……@@ -1802,14 +1803,16 @@ const make = Effect.gen(function* () {
1806+ const start: ProviderRuntimeIngestionShape["start"] = (activation) =>
18061807 Effect.gen(function* () {
1807- yield* Effect.forkScoped(
1808+ yield* forkParked(
MmacroscopeappBoton line 1808Aug 1, 2026

🟠 High Layers/ProviderRuntimeIngestion.ts:1808

forkParked delays Stream.runForEach(providerService.streamEvents, ...) until after activation completes, so the ingestion subscriber is not installed while the server waits at the activation boundary. ProviderService.streamEvents is a fresh PubSub subscription that does not replay missed events, so any provider events published during the prepared-to-activated interval are permanently dropped — lifecycle and message projections miss those events after handoff. The stream subscription needs to start before parking so events are queued, with processing deferred until activation completes.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts around line 1808:
2
3`forkParked` delays `Stream.runForEach(providerService.streamEvents, ...)` until after `activation` completes, so the ingestion subscriber is not installed while the server waits at the activation boundary. `ProviderService.streamEvents` is a fresh `PubSub` subscription that does not replay missed events, so any provider events published during the prepared-to-activated interval are permanently dropped — lifecycle and message projections miss those events after handoff. The stream subscription needs to start before parking so events are queued, with processing deferred until activation completes.

[GPT-5.6-SOL] RESPONDING ON BEHALF OF THEO

Verified as not reachable in this lifecycle. A fresh candidate has no active provider session or provider process that can publish runtime events: all HTTP and WebSocket ingress now waits on command readiness, orchestration roots and the session reaper are parked, and ProviderService only starts or recovers an adapter session in response to those commands. There is therefore no producer during the prepared-to-activated interval. Starting subscriptions early would add buffering machinery without an event source.

……@@ -1350,6 +1352,7 @@ const make = Effect.gen(function* () {
13501352 );
13511353 }),
13521354 );
1355+ yield* activation === undefined ? clearInterrupted : forkParked(activation, clearInterrupted);
MmacroscopeappBoton line 1355Aug 1, 2026

🟠 High Layers/ProviderCommandReactor.ts:1355

clearInterruptedThreadTitleRegenerations now runs behind activation via forkParked, so it reads the projection snapshot only after activation — concurrently with the newly-started domain-event stream. A thread.meta-updated regeneration request that arrives right after activation is picked up by this cleanup pass and completed with no title as if it were stale/interrupted, racing the worker that is actively processing the same request and silently discarding the generated title. The stale-request scan should capture request IDs before activation and defer only the correlated completions, or otherwise run cleanup before the stream starts accepting new events.

✅ Resolved in 32ae738ae53a35a89fb8caf9ad9982a95512b3da

[GPT-5.6-SOL] RESPONDING ON BEHALF OF THEO

Fixed in 32ae738ae. The reactor now snapshots the exact (threadId, requestId) pairs before installing the stream or waiting for activation, then dispatches only those correlated completions after activation.

apps/server/src/cloud/pinnedRuntime.tsline 198Outdated · d408091Open thread
……@@ -72,105 +68,137 @@ export const ensurePinnedRuntimeInstalled = Effect.fn("cloud.pinned_runtime.ensu
195+ yield* input.validate(paths);
196+ }
197+ return paths;
198+ }).pipe(
MmacroscopeappBoton line 198Aug 1, 2026

🟡 Medium cloud/pinnedRuntime.ts:198

Effect.tapError only runs its handler on typed failures, not on interruption. If the install fiber is interrupted during npm install, validation, sentinel writing, or rename, the .staging-* temp directory is left behind. Repeated interrupted installs accumulate full node_modules trees under runtime/versions, consuming disk space. Consider using Effect.ensuring so cleanup runs on both failure and interruption.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/server/src/cloud/pinnedRuntime.ts around line 198:
2
3`Effect.tapError` only runs its handler on typed failures, not on interruption. If the install fiber is interrupted during `npm install`, validation, sentinel writing, or rename, the `.staging-*` temp directory is left behind. Repeated interrupted installs accumulate full `node_modules` trees under `runtime/versions`, consuming disk space. Consider using `Effect.ensuring` so cleanup runs on both failure and interruption.
apps/server/src/serviceLauncher.tslines 363–367Outdated · d408091Open thread
……@@ -0,0 +1,659 @@
364+ if (child?.connected) child.disconnect();
365+ child?.unref();
366+ this.#rejectCompletion(error);
367+ }
MmacroscopeappBoton lines 363–367Aug 1, 2026

🟠 High src/serviceLauncher.ts:363

#fatal() calls disconnect() and unref() on the managed child but never terminates it. Because disconnect() only closes the IPC channel and unref() only lets the parent exit independently, the child process keeps running. Any transition failure while a child is alive (e.g., a durable state write or IPC send failure) rejects the launcher's completion promise while leaving the server process orphaned and still serving, which can conflict with a restarted launcher's new child. Terminate the child before rejecting completion.

diff
1- const child = this.#child?.process;
2- this.#child = null;
3- if (child?.connected) child.disconnect();
4- child?.unref();
5- this.#rejectCompletion(error);
6+ const child = this.#child?.process;
7+ this.#child = null;
8+ if (child !== undefined) {
9+ if (child.connected) child.disconnect();
10+ child.unref();
11+ await terminateChild(child);
12+ }
13+ this.#rejectCompletion(error);
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/server/src/serviceLauncher.ts around lines 363-367:
2
3`#fatal()` calls `disconnect()` and `unref()` on the managed child but never terminates it. Because `disconnect()` only closes the IPC channel and `unref()` only lets the parent exit independently, the child process keeps running. Any transition failure while a child is alive (e.g., a durable state write or IPC send failure) rejects the launcher's completion promise while leaving the server process orphaned and still serving, which can conflict with a restarted launcher's new child. Terminate the child before rejecting completion.
apps/server/src/cloud/pinnedRuntime.tsline 88Outdated · d408091Open thread
……@@ -72,105 +68,137 @@ export const ensurePinnedRuntimeInstalled = Effect.fn("cloud.pinned_runtime.ensu
85+ );
86+ const alreadyPinned =
87+ entryExists && Option.isSome(sentinel) && sentinel.value.trim() === input.version;
88+ if (versionDirExists && !alreadyPinned) {
MmacroscopeappBoton line 88Aug 1, 2026

🟠 High cloud/pinnedRuntime.ts:88

An interrupted install now leaves the pinned-runtime directory in a state that permanently blocks all future installs of that version. When versionDir exists but the sentinel or entry file is missing (the exact partial state left by a killed npm install), the function returns PinnedRuntimeInstallError with step "checking an incomplete pinned runtime" instead of cleaning up and retrying. Every subsequent ensurePinnedRuntimeInstalled call for that version hits the same check and fails identically, so the runtime can never repair itself without manual filesystem cleanup.

The previous implementation removed and rebuilt the incomplete directory; this change replaced that with an early failure. Consider either removing the incomplete versionDir and proceeding with a fresh install, or keeping the cleanup-and-retry path.

Separately, when validate fails on an already-pinned runtime (sentinel and entry exist but the runtime is corrupt), the function fails without removing the bad directory. Since the prior removePinnedRuntimeInstallation cleanup was removed in this PR, every retry re-validates the same corrupt runtime and fails again. Consider removing or rebuilding the runtime when validation fails so retries can recover.

Also found in 1 other location(s)

apps/server/src/cloud/selfUpdate.ts:92

ensurePinnedRuntimeInstalled revalidates an already-published runtime but leaves it in place when validate fails. This change removes the previous removePinnedRuntimeInstallation cleanup, so a runtime with a valid sentinel/entry that is corrupt or fails preflight is reused on every subsequent update attempt; retries can never reinstall that version and always fail until the directory is manually deleted.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/server/src/cloud/pinnedRuntime.ts around line 88:
2
3An interrupted install now leaves the pinned-runtime directory in a state that permanently blocks all future installs of that version. When `versionDir` exists but the sentinel or entry file is missing (the exact partial state left by a killed `npm install`), the function returns `PinnedRuntimeInstallError` with step `"checking an incomplete pinned runtime"` instead of cleaning up and retrying. Every subsequent `ensurePinnedRuntimeInstalled` call for that version hits the same check and fails identically, so the runtime can never repair itself without manual filesystem cleanup.
4
5The previous implementation removed and rebuilt the incomplete directory; this change replaced that with an early failure. Consider either removing the incomplete `versionDir` and proceeding with a fresh install, or keeping the cleanup-and-retry path.
6
7Separately, when `validate` fails on an already-pinned runtime (sentinel and entry exist but the runtime is corrupt), the function fails without removing the bad directory. Since the prior `removePinnedRuntimeInstallation` cleanup was removed in this PR, every retry re-validates the same corrupt runtime and fails again. Consider removing or rebuilding the runtime when validation fails so retries can recover.
8
9Also found in 1 other location(s):
10- apps/server/src/cloud/selfUpdate.ts:92 -- `ensurePinnedRuntimeInstalled` revalidates an already-published runtime but leaves it in place when `validate` fails. This change removes the previous `removePinnedRuntimeInstallation` cleanup, so a runtime with a valid sentinel/entry that is corrupt or fails preflight is reused on every subsequent update attempt; retries can never reinstall that version and always fail until the directory is manually deleted.

[GPT-5.6-SOL] RESPONDING ON BEHALF OF THEO

Fixed in 32ae738ae. Incomplete final directories are removed and rebuilt. A completed runtime that fails install validation is also removed and reinstalled, while a distinct tagged preflight-blocked error preserves valid runtimes that are merely incompatible with the current migration frontier.

apps/server/src/serviceLauncher.tslines 403–404Outdated · d408091Open thread
……@@ -0,0 +1,659 @@
401+ }
402+
403+ async #startChild(version: string, role: ChildRole, update?: ServiceUpdateRecord): Promise<void> {
404+ if (!(await runtimeExists(this.#baseDir, version))) {
MmacroscopeappBoton lines 403–404Aug 1, 2026

🟠 High src/serviceLauncher.ts:403

stop() runs outside the #transitions queue, so shutdown races with in-progress startup or rollback transitions. If a signal arrives while #recover() or #startChild() is awaiting runtimeExists() or spawn, stop() sees #child === null, resolves completion, and then the transition installs a new child afterward — that child never receives the shutdown signal and is left running. The same race affects #rollback: after it writes state but before #startChild spawns, stop() can clear the current child and resolve, after which the transition still spawns a new child because #startChild does not check #stopping. Serialize stop() through #transitions or re-check #stopping inside #startChild before publishing the spawned child.

diff
1 async #startChild(version: string, role: ChildRole, update?: ServiceUpdateRecord): Promise<void> {
2+ if (this.#stopping || this.#finished) return;
3 if (!(await runtimeExists(this.#baseDir, version))) {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/server/src/serviceLauncher.ts around lines 403-404:
2
3`stop()` runs outside the `#transitions` queue, so shutdown races with in-progress startup or rollback transitions. If a signal arrives while `#recover()` or `#startChild()` is awaiting `runtimeExists()` or `spawn`, `stop()` sees `#child === null`, resolves completion, and then the transition installs a new child afterward — that child never receives the shutdown signal and is left running. The same race affects `#rollback`: after it writes state but before `#startChild` spawns, `stop()` can clear the current child and resolve, after which the transition still spawns a new child because `#startChild` does not check `#stopping`. Serialize `stop()` through `#transitions` or re-check `#stopping` inside `#startChild` before publishing the spawned child.
MmacroscopeappBotreviewed · 3 inline threadsAug 1, 2026
Synced from GitHub

Reviewed the new service modules and their call sites against the Effect service conventions. Six findings, mostly around dependency acquisition (module globals / global process instead of the Effect environment) and error modelling (a step string used to drive caller-visible messages). Details inline.

Posted via Macroscope — Effect Service Conventions

apps/server/src/cloud/selfUpdate.tslines 150–166Outdated · d408091Open thread
……@@ -237,208 +75,114 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option
163+ error.step.startsWith("This release requires")
164+ ? error.step
165+ : `Could not prepare t3@${targetVersion}: ${error.message}`,
166+ ),
MmacroscopeappBoton lines 150–166Aug 1, 2026

cloud/selfUpdate.ts:166

PinnedRuntimeInstallError.step is a diagnostic stage name (message renders it as Pinned runtime install failed while ${step}.), but here a full user-facing sentence from preflight.reason is stuffed into it and the caller recovers it by prefix-sniffing (startsWith("This version includes")). That both derives the wrapper message from an unstructured field and uses a discriminator-by-string to choose caller-visible text.

Suggest a distinct tagged error for the blocked-preflight case (e.g. ServicePreflightBlockedError with version and reason, message derived from them) failed from validate, and recovering it with Effect.catchTags({ ServicePreflightBlockedError: ... }) instead of prefix matching on step.

No longer relevant as of 32ae738ae53a35a89fb8caf9ad9982a95512b3da

[GPT-5.6-SOL] RESPONDING ON BEHALF OF THEO

Fixed in 32ae738ae. A blocked preflight now fails with PinnedRuntimePreflightBlockedError { version, reason }, and the caller branches on the tag rather than inspecting a diagnostic step string.

apps/server/src/persistence/Layers/Sqlite.tslines 80–80Outdated · d408091Open thread
……@@ -59,10 +70,13 @@ export const makeSqlitePersistenceLive = Effect.fn("makeSqlitePersistenceLive")(
67- Effect.map(Effect.service(ServerConfig), ({ dbPath }) => makeSqlitePersistenceLive(dbPath)),
78+ Effect.gen(function* () {
79+ const { dbPath } = yield* ServerConfig;
80+ return makeSqlitePersistenceLive(dbPath, { trial: isServiceLauncherTrial() });
MmacroscopeappBoton lines 80–80Aug 1, 2026

isServiceLauncherTrial() re-reads and re-decodes the launcher context from the global process.env inside a layer, duplicating state that ServiceLauncherClient already exposes as trial — and with different strictness (the client fails on invalid context, this silently returns false, so a corrupt context would run migrations in a trial child).

Suggest acquiring the flag from the service instead, e.g. const launcher = yield* ServiceLauncherClient.ServiceLauncherClient in this Layer.unwrap (or threading trial in from makeServerLayer, which already provides that layer), so persistence has one visible source of truth.

Posted via Macroscope — Effect Service Conventions

apps/server/src/cloud/serviceLauncherClient.tslines 206–213Outdated · d408091Open thread
……@@ -0,0 +1,356 @@
210+ const host = options?.process ?? process;
211+ const currentVersion = options?.currentVersion ?? packageJson.version;
212+ const rawContext = host.env[SERVICE_LAUNCHER_CONTEXT_ENV];
213+ const context = rawContext === undefined ? undefined : decodeContext(rawContext);
MmacroscopeappBoton lines 206–213Aug 1, 2026

cloud/serviceLauncherClient.ts:213

make (and isServiceLauncherManaged / isServiceLauncherTrial) default to the global process and read process.env directly, so the launcher context is acquired outside the Effect environment and the options.process parameter is a test-only seam rather than a declared dependency. packages/shared/src/hostProcess.ts already models this with HostProcessEnvironment (and HostProcessExecutablePath, etc.), which this file's own dependents use.

Suggest reading the context with const env = yield* HostProcessEnvironment inside make, and — if the IPC handle itself must be overridable — exposing it as a Context.Reference defaulting to process instead of an options.process default, so the requirement shows up in layer. The sync helpers called from Effect code (resolveServerSelfUpdateCapability, persistence/Layers/Sqlite.ts) should then read the same reference rather than the global.

No longer relevant as of 32ae738ae53a35a89fb8caf9ad9982a95512b3da

apps/server/src/cloud/pinnedRuntime.tsline 96Outdated · d408091Open thread
……@@ -72,105 +68,137 @@ export const ensurePinnedRuntimeInstalled = Effect.fn("cloud.pinned_runtime.ensu
93+ if (alreadyPinned) {
94+ yield* input.validate(paths);
95+ return paths;
96+ }

Broken pinned runtimes block retries

High Severity

ensurePinnedRuntimeInstalled now hard-fails on an incomplete versionDir, and when an already-pinned tree fails validate it leaves that tree in place. The previous path removed broken installs so the same version could be retried; without that cleanup, remote updates and service install can stay stuck on a known-bad runtime until manual deletion.

Reviewed by Cursor Bugbot for commit d40809167087e69d2493586a1aedad6eff8003fd. Configure here.

[GPT-5.6-SOL] RESPONDING ON BEHALF OF THEO

Fixed in 32ae738ae. Incomplete installs are deleted and rebuilt, completed installs that fail validation are reinstalled, staging is cleaned on interruption, and focused tests cover all three recovery paths.

apps/server/src/persistence/Layers/Sqlite.tsline 45Outdated · d408091Open thread
……@@ -30,24 +31,34 @@ const makeRuntimeSqliteLayer = Effect.fn("makeRuntimeSqliteLayer")(function* (
42+ // All durable workers are still parked. Restore the connection before
43+ // prepared so activation itself cannot fail on database setup.
44+ yield* sql`PRAGMA query_only = OFF;`;
45+ return;

Trial opens database before commit

High Severity

Trial setup turns PRAGMA query_only off immediately after the migration check, long before the launcher commits. Ingress that is not parked behind activation—such as HTTP orchestration.dispatch—can therefore write to the shared database while the candidate is still uncommitted, so a rolled-back trial can leave durable mutations behind.

Reviewed by Cursor Bugbot for commit d40809167087e69d2493586a1aedad6eff8003fd. Configure here.

Added 1 commit

  • fix(server): close remote update race windows32ae738
Synced from GitHub

Reviewed the Effect service changes in this PR (launcher client, shutdown service, self-update, pinned runtime, startup/activation wiring). The prior findings on the module-global shutdown channel, the free-form launcher error, duplicated launcher-context reads in the SQLite layer, and the double ServiceLauncherClient layer are all resolved. Three smaller convention issues remain in the new code.

Posted via Macroscope — Effect Service Conventions

Added 1 commit

  • fix(server): preserve remote update failures495da55
……@@ -1315,7 +1322,15 @@ const make = Effect.gen(function* () {
1330+ { cause: Cause.pretty(cause) },
1331+ ).pipe(Effect.as([])),
1332+ ),
1333+ );
Ccursor[bot]on line 1333Aug 1, 2026

Find path swallows fiber interrupts

Medium Severity

findInterruptedThreadTitleRegenerations is wrapped in catchCause that logs and returns [] for every cause, including interrupts. Nearby clear/dispatch paths re-raise interrupt-only causes with Cause.hasInterruptsOnly. During scope shutdown this lets start keep forking parked work instead of aborting.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 32ae738ae53a35a89fb8caf9ad9982a95512b3da. Configure here.

[GPT-5.6-SOL] RESPONDING ON BEHALF OF THEO

Fixed on the latest head. Pure interruption is re-raised in both startup cleanup paths instead of being logged and recovered; the focused ProviderCommandReactor suite passes.

apps/server/src/cloud/pinnedRuntime.tsline 100Outdated · 495da55Open thread
……@@ -72,105 +80,156 @@ export const ensurePinnedRuntimeInstalled = Effect.fn("cloud.pinned_runtime.ensu
97+ );
98+ const alreadyPinned =
99+ entryExists && Option.isSome(sentinel) && sentinel.value.trim() === input.version;
100+ if (versionDirExists && !alreadyPinned) {
MmacroscopeappBoton line 100Aug 1, 2026

🟠 High cloud/pinnedRuntime.ts:100

Two concurrent callers can delete each other's published runtime. With the process-wide lock removed, both callers can observe an incomplete versionDir. Caller A removes it and atomically publishes a valid staged runtime via fs.rename. Caller B — which already passed the versionDirExists && !alreadyPinned check — then executes its delayed fs.remove(paths.versionDir), deleting A's freshly published runtime. If B's own install subsequently fails, no valid runtime remains. The fs.remove at line 101 runs unconditionally based on a stale observation, with no check that the directory is still the same incomplete one that was inspected. The removal must be serialized or made conditional on the directory not having changed between the check and the delete.

✅ Resolved in 73c62840a6e5f4435dba4d549121f404148ec862

MmacroscopeappBotreviewed · 3 inline threadsAug 1, 2026
Synced from GitHub

Three findings, all in the rewritten test files for the new service-launcher update path. The production service modules (serviceLauncherClient.ts, serverShutdown.ts, selfUpdate.ts, Sqlite.ts) now follow the tag/make/layer shape, acquire their dependencies from the environment, and the earlier findings about the module-global shutdown channel, the free-form launcher error, the duplicated launcher client, and the catchTags recovery are resolved.

Posted via Macroscope — Effect Service Conventions

apps/server/src/cloud/bootService.test.tslines 16–16Outdated · 495da55Open thread
……@@ -1,558 +1,98 @@
92- const unit = BootService.renderBootServiceUnit({
93- nodePath: "/usr/local/bin/node",
94- t3EntryPath: "/home/theo/.t3/runtime/versions/0.0.27/node_modules/t3/dist/bin.mjs",
16+import { make, renderBootServiceUnit } from "./bootService.ts";
MmacroscopeappBoton lines 16–16Aug 1, 2026

This file previously imported the service module as a namespace (import * as BootService from "./bootService.ts"). A bare make import erases the module namespace at a service boundary; consider keeping import * as BootService from "./bootService.ts" and calling BootService.make(...) / BootService.renderBootServiceUnit(...).

Same pattern in the two sibling tests: selfUpdate.test.ts:14 (import { make } from "./selfUpdate.ts") and serviceLauncherClient.test.ts:12 (import { make, ServiceLauncherHostProcess } from "./serviceLauncherClient.ts").

Posted via Macroscope — Effect Service Conventions

[GPT-5.6-SOL] RESPONDING ON BEHALF OF THEO

Fixed in d3b491995. All three service-boundary tests now use namespace imports.

apps/server/src/cloud/selfUpdate.test.tslines 90–90Outdated · 495da55Open thread
……@@ -1,632 +1,93 @@
463- assert.include(error.reason, "did not report the requested");
464- assert.isFalse(yield* context.fs.exists(versionDir));
465- assert.lengthOf(context.spawns, 0);
90+ expect(order).toEqual(["install", "preflight", "accept"]);
MmacroscopeappBoton lines 90–90Aug 1, 2026

update still has several behavioral branches that no longer have any test: non-exact target versions, the desktop-managed refusal, the concurrent-update guard, and surfacing PinnedRuntimePreflightBlockedError.reason verbatim to the caller (the migration-boundary message users are told to act on). The previous suite covered the equivalent branches and was removed. Consider adding focused tests for the blocked-preflight reason and at least one rejection branch.

Posted via Macroscope — Effect Service Conventions

[GPT-5.6-SOL] RESPONDING ON BEHALF OF THEO

I am keeping the reduced focused coverage here. servicePreflight.test.ts directly asserts the actionable migration-block message, while this test proves install → preflight → launcher acceptance ordering and ID propagation. Restoring the deleted guard matrix would re-expand scope around small existing guards without improving the new state-machine proof.

[GPT-5.6-SOL] RESPONDING ON BEHALF OF THEO


Added focused coverage in d393dabf5 for invalid versions, desktop-managed refusal, verbatim preflight-block reasons, and the concurrent-update guard, alongside the existing stage/preflight/accept ordering test.

apps/server/src/cloud/bootService.test.tslines 95–95Outdated · 495da55Open thread
……@@ -1,558 +1,98 @@
92+ });
93+ expect(yield* fs.readFileString(plan.launcherPath)).toBe("export {};\n");
94+ expect(commands).toContain("systemctl --user restart t3code.service");
95+ expect(commands.some((command) => command.startsWith("npm "))).toBe(false);
MmacroscopeappBoton lines 95–95Aug 1, 2026

install gained a new multi-artifact rollback (stop the unit, restore the previous launcher/state/unit, daemon-reload, then restart or disable) and status.current gained new launcher/sentinel/state conditions, but the rewrite leaves only this happy-path test. The previous suite's coverage for failed activation cleanup, uninstall, the non-Linux unsupported path, and reconcileService idempotency was deleted without replacement, and all of that behavior still exists. Consider restoring focused tests for at least the rollback path and uninstall.

Posted via Macroscope — Effect Service Conventions

[GPT-5.6-SOL] RESPONDING ON BEHALF OF THEO


Added focused coverage in d393dabf5 for current versus pending status, uninstall, unsupported platforms, and restarting an installed unit when repair fails. The shared fixture keeps those cases compact.

Added 2 commits

  • refactor(server): simplify remote update handoff95aad8e
  • fix(server): avoid pinned runtime publish racesd3b4919
apps/server/src/server.tsline 437Outdated · d3b4919Open thread
……@@ -418,13 +425,26 @@ export const makeRoutesLayer = Layer.mergeAll(
426434 Effect.gen(function* () {
427435 const config = yield* ServerConfig.ServerConfig;
436+ const launcherClient = yield* ServiceLauncherClient.make();
437+ const activation = yield* Deferred.make<void>();
MmacroscopeappBoton line 437Aug 1, 2026

🟠 High src/server.ts:437

When launcher.prepareTrial fails (IPC disconnect or rejection), the activation Deferred is never completed, so awaitActivation inside runtimeStateLayer's acquire blocks forever. This prevents makeServerLayer from finishing launching, and the trial process hangs instead of exiting so the service launcher can roll it back. The tailscaleServeLayer acquire has the same issue — it also awaits awaitActivation and blocks indefinitely on the same path. Consider failing the activation Deferred (or failing the layer) when startup fails, so the auxiliary layers complete their acquire and the process can exit.

✅ Resolved in 06f189cfe8ebdd50f840ea4b475bbba1157b2533

[GPT-5.6-SOL] RESPONDING ON BEHALF OF THEO\n-----\n\nFixed in 06f189cfe. Startup failure now defects the existing activation Deferred. The auxiliary layer acquisitions unwind without opening the gate, so Layer.launch exits and the launcher can roll the trial back immediately.

apps/server/src/cloud/serviceLauncherClient.tslines 182–183Outdated · d3b4919Open thread
……@@ -0,0 +1,225 @@
180+ }
181+
182+ return Effect.sync(cleanup);
183+ });
MmacroscopeappBoton lines 182–183Aug 1, 2026

🟠 High cloud/serviceLauncherClient.ts:182

exchange registers IPC listeners and sends a message but never times out waiting for a reply. If the launcher stays connected but never sends an acceptable response (malformed/unexpected messages or a stall), the effect never completes — requestUpdate hangs with the self-update lock held, and prepareTrial blocks indefinitely at the server activation boundary, leaving the trial server hung. Add a bounded timeout that cleans up the listeners and fails the exchange.

✅ Resolved in 06f189cfe8ebdd50f840ea4b475bbba1157b2533

Synced from GitHub

Two findings in apps/server/src/cloud/bootService.ts. Previously reported issues that were addressed (namespace import for serviceLauncherClient, Effect.catchTags in pinnedRuntime.ts, structured ServiceLauncherClientError/PinnedRuntimePreflightBlockedError, launcher client acquired from the environment and composed once) are not repeated.

Posted via Macroscope — Effect Service Conventions

Ccursor[bot]reviewed · 3 inline threadsAug 1, 2026
Synced from GitHub

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit d3b491995776b33fa434a19460bc3163375f28cc. Configure here.

apps/server/src/serverRuntimeStartup.tsline 479Outdated · d3b4919Open thread
……@@ -288,191 +290,207 @@ const runStartupPhase = <A, E, R>(phase: string, effect: Effect.Effect<A, E, R>)
476+ });
477+ return Effect.logError("server runtime startup failed", {
478+ cause: startupExit.cause,
479+ }).pipe(Effect.andThen(commandGate.failCommandReady(error)));

Trial failure no longer exits

High Severity

Startup runs in a forked fiber and now only fails the command gate on error. The trial-specific shutdown.request(1) path was removed with ServerShutdown, so a failed trial stays alive until the launcher’s 120s prepared timeout instead of exiting so the launcher can roll back immediately.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit d3b491995776b33fa434a19460bc3163375f28cc. Configure here.

[GPT-5.6-SOL] RESPONDING ON BEHALF OF THEO\n-----\n\nFixed in 06f189cfe without restoring the shutdown coordinator. Startup failure defects the closed activation gate, which fails the waiting application-layer acquisitions and causes the trial process to exit immediately.

apps/server/src/cloud/pinnedRuntime.tsline 194Outdated · d3b4919Open thread
……@@ -72,105 +80,121 @@ export const ensurePinnedRuntimeInstalled = Effect.fn("cloud.pinned_runtime.ensu
191+ (cause) =>
192+ new PinnedRuntimeInstallError({ step: "publishing the pinned runtime", cause }),
193+ ),
194+ );

Publish can erase pinned runtime

High Severity

Publishing deletes versionDir before renaming the staging tree, then Effect.ensuring always deletes staging. If rename fails after the remove, both the previous runtime and the staged replacement are gone, contradicting the “never leave no valid runtime” comment and leaving that version uninstallable until a full reinstall.

Reviewed by Cursor Bugbot for commit d3b491995776b33fa434a19460bc3163375f28cc. Configure here.

apps/server/src/cloud/bootService.tsline 332Outdated · d3b4919Open thread
……@@ -244,150 +241,106 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
329+ "--user",
330+ "restart",
331+ BOOT_SERVICE_UNIT_FILE,
332+ ]);

Service install lost failure rollback

Medium Severity

install still stops an existing unit and durably rewrites launcher, state, and unit files, but the previous restore-and-restart tapError path is gone. If daemon-reload, enable, linger, or restart fails afterward, the machine can be left with a stopped service and partially applied admin state and no automatic return to the prior unit.

Reviewed by Cursor Bugbot for commit d3b491995776b33fa434a19460bc3163375f28cc. Configure here.

MmacroscopeappBotreviewed · 2 inline threadsAug 1, 2026
Synced from GitHub

Two remaining convention issues in the newly touched service code; earlier findings on selfUpdate.ts, pinnedRuntime.ts, and serviceLauncherClient.ts look addressed.

Posted via Macroscope — Effect Service Conventions

apps/server/src/persistence/Layers/Sqlite.tslines 10–10Outdated · d3b4919Open thread
……@@ -7,6 +7,7 @@ import type { SqlError } from "effect/unstable/sql/SqlError";
77
88import { runMigrations } from "../Migrations.ts";
99import { ServerConfig } from "../../config.ts";
10+import { ServiceLauncherClient } from "../../cloud/serviceLauncherClient.ts";
MmacroscopeappBoton lines 10–10Aug 1, 2026

Layers/Sqlite.ts:10

serviceLauncherClient.ts is a service module consumed here at a service boundary (yield* ServiceLauncherClient on line 74), so it should be imported as a namespace and used through its module shape — the other consumers added in this PR (server.ts, serverRuntimeStartup.ts, selfUpdate.ts) already do.

diff
1-import { ServiceLauncherClient } from "../../cloud/serviceLauncherClient.ts";
2+import * as ServiceLauncherClient from "../../cloud/serviceLauncherClient.ts";

and at the use site:

diff
1- const launcher = yield* ServiceLauncherClient;
2+ const launcher = yield* ServiceLauncherClient.ServiceLauncherClient;

No longer relevant as of 73c62840a6e5f4435dba4d549121f404148ec862

apps/server/src/cloud/bootService.tslines 148–148Outdated · d3b4919Open thread
……@@ -180,32 +139,70 @@ export class BootService extends Context.Service<
145+const writeDurably = (filePath: string, contents: string) =>
146+ Effect.tryPromise({
147+ try: async () => {
148+ const directory = filePath.slice(0, Math.max(filePath.lastIndexOf("/"), 1));
MmacroscopeappBoton lines 148–148Aug 1, 2026

writeDurably re-derives the parent directory by slicing on a literal / and creates it with NodeFSP.mkdir, even though make already acquires Path.Path and FileSystem.FileSystem from the environment (and atomicWrite.ts already owns the temp-write/rename part). Consider defining the helper inside make (or passing path) so directory derivation and creation go through the acquired services and the node:fs/promises escape hatch is limited to the fsync work that Effect's FileSystem cannot express.

Posted via Macroscope — Effect Service Conventions

[GPT-5.6-SOL] RESPONDING ON BEHALF OF THEO\n-----\n\nFixed in 73c62840a. The helper now lives inside make and uses the acquired Path and FileSystem services for directory derivation, temp creation, atomic rename, and both fsync operations.

Added 1 commit

  • fix(server): bound launcher activation waits06f189c
apps/server/src/serverRuntimeStartup.tsline 436Outdated · 06f189cOpen thread
……@@ -288,191 +290,211 @@ const runStartupPhase = <A, E, R>(phase: string, effect: Effect.Effect<A, E, R>)
433+ // This is the prepared boundary. Every dependency has been acquired and
434+ // every runtime root has confirmed that it is parked before this request.
435+ const updateOutcome = yield* launcher.prepareTrial;
436+ yield* options?.activate ?? Effect.void;
MmacroscopeappBoton line 436Aug 1, 2026

🟡 Medium src/serverRuntimeStartup.ts:436

The generic welcome event published after options.activate can overwrite the auto-bootstrapped welcome event that was published in the parked fiber, silently dropping bootstrapProjectId and bootstrapThreadId. When forkParked releases the auto-bootstrap fiber at the activation boundary, startup immediately proceeds to commandGate.signalCommandReady and publishes a second welcome event without waiting for the parked fiber to complete. If the generic event arrives last, ServerLifecycleEvents replaces the snapshot's welcome event, so clients connecting afterward receive a welcome snapshot missing the bootstrap targets. Consider awaiting the auto-bootstrap fiber before publishing the generic welcome, or reordering so the generic welcome is published before the parked fiber is released.

✅ Resolved in 74e600f8332ec159539f590fdd97aec281f82817

[GPT-5.6-SOL] RESPONDING ON BEHALF OF THEO


Fixed in 74e600f83. After durable commit, the generic welcome is published while the activation gate is still closed. Opening the gate afterward means the richer parked auto-bootstrap welcome can only arrive later and remain the snapshot.

MmacroscopeappBotreviewed · 3 inline threadsAug 1, 2026
Synced from GitHub

Reviewed the changed Effect service code. The service/layer/error structure of the new launcher modules looks consistent with the conventions (namespace imports at service boundaries, Context.Service tags with inline interfaces, Schema.TaggedErrorClass failures with structural attributes, catchTags for known tags).

Findings in this revision are limited to documentation/invariant loss during the move: several comments describing behavior that still exists were dropped from bootService.ts, pinnedRuntime.ts, and the version rule moved into serviceProtocol.ts.

Earlier findings from previous revisions of this PR are still open and are intentionally not re-posted: the named ServiceLauncherClient import in persistence/Layers/Sqlite.ts, the node:fs/promises-based writeDurably in bootService.ts that bypasses the acquired FileSystem/Path services, and the collapse of every PinnedRuntimeInstallError into the generic BootServiceInstallError.

Posted via Macroscope — Effect Service Conventions

apps/server/src/cloud/serviceProtocol.tslines 54–54Outdated · 06f189cOpen thread
……@@ -0,0 +1,219 @@
51+ readonly updateId: string;
52+ };
53+
54+export const isExactServiceVersion = (version: string): boolean =>
MmacroscopeappBoton lines 54–54Aug 1, 2026

cloud/serviceProtocol.ts:54

This is selfUpdate.ts's EXACT_VERSION_PATTERN moved here, but its documented invariant was lost: exact versions only (never dist-tags) so the acknowledgement names the version that was actually installed, and so the value stays safe to pass to npm and to embed in filesystem paths. Consider restoring that comment above the helper.

No longer relevant as of 74e600f8332ec159539f590fdd97aec281f82817

apps/server/src/cloud/pinnedRuntime.tslines 152–152Outdated · 06f189cOpen thread
……@@ -72,105 +80,121 @@ export const ensurePinnedRuntimeInstalled = Effect.fn("cloud.pinned_runtime.ensu
149+ "--no-audit",
150+ `t3@${input.version}`,
151+ ],
152+ timeout: PINNED_RUNTIME_INSTALL_TIMEOUT,
MmacroscopeappBoton lines 152–152Aug 1, 2026

The rationale for this timeout was dropped while the timeout itself moved unchanged. Consider carrying it over so the value is not tuned away later.

suggestion
1 // Native deps (node-pty) can compile from source on slow boxes; the
2 // ProcessRunner default of 60s would kill a healthy install.
3 timeout: PINNED_RUNTIME_INSTALL_TIMEOUT,

Posted via Macroscope — Effect Service Conventions

apps/server/src/cloud/bootService.tslines 70–70Outdated · 06f189cOpen thread
……@@ -102,7 +66,8 @@ export function renderBootServiceUnit(plan: BootServicePlan): string {
10468 `Environment=${BOOT_SERVICE_UNIT_ENV}=${BOOT_SERVICE_UNIT_FILE}`,
105- `ExecStart=${quoteSystemdValue(plan.nodePath)} ${quoteSystemdValue(plan.t3EntryPath)} serve`,
69+ `ExecStart=${quoteSystemdValue(plan.nodePath)} ${quoteSystemdValue(plan.launcherPath)}`,
70+ "KillMode=control-group",
MmacroscopeappBoton lines 70–70Aug 1, 2026

This rewrite drops invariant documentation for code that is still here: the %-specifier rationale on escapeSystemdSpecifiers/quoteSystemdValue (including that append: paths take the rest of the line literally and must not be quoted), the renderBootServiceUnit notes (pure/byte-for-byte testable, minimal user-manager environment requiring absolute paths, the deliberate absence of After=network-online.target, the StartLimitBurst reasoning, and restart rather than enable --now), and the field/method docs on BootServicePlan, BootServiceStatus.current, and BootService.install/uninstall. Consider restoring the comments that still describe current behavior.

Posted via Macroscope — Effect Service Conventions

[GPT-5.6-SOL] RESPONDING ON BEHALF OF THEO


Restored the operational comments that prevent mistakes: systemd percent expansion and append paths, shell/PATH-free unit rendering, and the deliberate absence of a network-online target. I kept self-evident field and method prose removed so the documentation does not grow back into implementation noise.

Added 1 commit

  • fix(server): preserve pinned runtime publish safety73c6284
apps/server/src/cloud/bootService.tsline 285Outdated · 73c6284Open thread
……@@ -244,150 +223,118 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
335- // running process, so repairing a stale unit would leave the old
336- // server running until reboot. restart also starts a stopped service.
337- yield* runStep("starting the service", "systemctl", [
285+ const installed = yield* fs
MmacroscopeappBoton line 285Aug 1, 2026

🟠 High cloud/bootService.ts:285

The install effect stops the running service (line 289–294) before performing several fallible writes and systemctl/loginctl invocations, but the rollback handler that previously restored the service on failure was removed in this PR. If any subsequent step fails — writing the launcher, state, or unit file, daemon-reload, enable, enable-linger, or restart — the effect aborts with the previously working service left stopped and its unit file potentially overwritten, causing an avoidable remote-server outage that requires manual recovery. Consider restoring the prior rollback behavior: snapshot the old unit file before stopping, and on any failure after the stop, restore the old unit (or remove it if none existed), run daemon-reload, and restart the service.

No longer relevant as of 857ff043330324e6f4083a11e349b6c2615d3957

[GPT-5.6-SOL] RESPONDING ON BEHALF OF THEO


Addressed in e05b28a6b with smaller recovery. On any failure after stopping an installed unit, the installer attempts to restart that unit before returning the original error. The runtime is prepared first and each administrative artifact is atomically and durably replaced, so systemd restarts either its old loaded selection or a fully written new one. This avoids leaving the service down without reintroducing a three-artifact snapshot/restore transaction.

Added 1 commit

  • fix(server): preserve bootstrap welcome ordering74e600f
MmacroscopeappBotreviewed · 1 inline threadAug 1, 2026
Synced from GitHub

One convention finding on the new launcher client service module. Everything else in the changed Effect service scope (namespace imports at service boundaries, make/layer shape for bootService/selfUpdate/serverRuntimeStartup, dependency acquisition through FileSystem/Path/ProcessRunner/HostProcess*, structured tagged errors) looks consistent with the conventions.

Posted via Macroscope — Effect Service Conventions

apps/server/src/cloud/serviceLauncherClient.tslines 232–232Outdated · 74e600fOpen thread
……@@ -0,0 +1,232 @@
229+ requestUpdate,
230+ prepareTrial,
231+ });
232+});
MmacroscopeappBoton lines 232–232Aug 1, 2026

cloud/serviceLauncherClient.ts:232

This module owns construction (make acquires ServiceLauncherHostProcess and HostProcessEnvironment and can fail), but it exports no canonical layer, so server.ts runs make() by hand and hand-rolls Layer.succeed(ServiceLauncherClient.ServiceLauncherClient, launcherClient). Since ServiceLauncherClient is a Context.Reference with an unmanaged default, a consumer that misses that ad-hoc layer silently gets the fallback instead of a type error.

Consider exporting the canonical layer here and providing that single layer value in server.ts (layer memoization keeps one instance across the two Layer.provide sites):

diff
1 import * as Effect from "effect/Effect";
2+import * as Layer from "effect/Layer";
3 import * as Schema from "effect/Schema";
diff
1 });
2+
3+export const layer = Layer.effect(ServiceLauncherClient, make());

No longer relevant as of 857ff043330324e6f4083a11e349b6c2615d3957

Added 1 commit

  • fix(server): restart service after local update failuree05b28a
Synced from GitHub

Reviewed the Effect service changes in this PR (launcher client, boot service, pinned runtime, activation/startup wiring, persistence layer). Prior findings on namespace imports, Path/FileSystem acquisition in bootService, the blocked-preflight error tag, and structured error context all look addressed. Three remaining items below.

Posted via Macroscope — Effect Service Conventions

Added 1 commit

  • refactor(server): centralize launcher client layer857ff04

Added 1 commit

  • fix(server): reject malformed service versions06955e0

Added 1 commit

  • refactor(server): make launcher client explicitd393dab
MmacroscopeappBotreviewed · 2 inline threadsAug 1, 2026
Synced from GitHub

Two error-modeling findings in the new serviceLauncherClient.ts. Everything else in the reviewed scope (the BootService/ServerSelfUpdate/ServiceLauncherClient definitions, make/layer exports, namespace imports, and dependency acquisition through the Effect environment) matches the conventions.

Posted via Macroscope — Effect Service Conventions

apps/server/src/cloud/serviceLauncherClient.tslines 20–35Outdated · d393dabOpen thread
……@@ -0,0 +1,244 @@
32+ },
33+) {
34+ override get message(): string {
35+ return this.reason;
MmacroscopeappBoton lines 20–35Aug 1, 2026

One error covers eight operations, but its message returns the free-form reason rather than being derived from structural attributes, and the operation discriminator is what selects caller-visible text: selfUpdate.ts does error.operation === "rejected" ? error.reason : "Could not ask the service launcher…". Only the launcher rejection carries a reason authored elsewhere that must reach the user; the other seven reasons are hardcoded sentences that operation already identifies.

Consider splitting the rejection into its own tagged error (e.g. ServiceLauncherRejectedError with targetVersion + launcher reason) so callers can pass it through by tag, and deriving the remaining error's message from operation instead of storing a sentence as data.

Posted via Macroscope — Effect Service Conventions

apps/server/src/cloud/serviceLauncherClient.tslines 89–96Outdated · d393dabOpen thread
……@@ -0,0 +1,244 @@
93+) =>
94+ cause === undefined
95+ ? new ServiceLauncherClientError({ operation, reason })
96+ : new ServiceLauncherClientError({ operation, reason, cause });
MmacroscopeappBoton lines 89–96Aug 1, 2026

cloud/serviceLauncherClient.ts:96

This helper's only behavior is new ServiceLauncherClientError({ ... }), so the error's attributes and cause are hidden behind positional arguments at every failure site. Consider constructing the error inline at each failure boundary (or, if the optional cause spread is the motivation, keeping the construction explicit with ...(cause === undefined ? {} : { cause })).

No longer relevant as of ff26b5a2202bbdb58d2842f2d8e368f09621ceb1

[GPT-5.6-SOL] RESPONDING ON BEHALF OF THEO


Fixed in ff26b5a22. Removed the constructor wrapper and made every ServiceLauncherClientError construction explicit at its failure boundary, including cause where present.

Added 1 commit

  • refactor(server): separate launcher rejection errorsff26b5a