Skip to content

fix(server): avoid pinned runtime publish races

MacroscopeApp / Macroscope - Effect Service Conventions failed Aug 1, 2026 in 3m 29s

Effect Service Conventions: 2 issues found

Two convention violations found in the changed service code.

  • apps/server/src/persistence/Layers/Sqlite.ts: local service module serviceLauncherClient.ts is imported with a named import (import { ServiceLauncherClient }) at a service boundary instead of as a module namespace, unlike the other consumers added in this PR.
  • apps/server/src/cloud/bootService.ts: the new writeDurably helper derives and creates the parent directory with a hardcoded / slice plus node:fs/promises, bypassing the Path.Path / FileSystem.FileSystem dependencies the service already acquires (and duplicating atomicWrite.ts).

Both were posted as inline review comments.

Details

Note

Your check run agent prompt is: .macroscope/check-run-agents/effect-service-conventions.md
More information about how Check Run Agents work can be found in our Help Center.

Verified as already addressed since prior runs, so not re-flagged:

  • selfUpdate.ts now imports serviceLauncherClient.ts as a namespace and passes the underlying error as cause to failWith.
  • pinnedRuntime.ts recovers the known tagged failure with Effect.catchTags, and the blocked-preflight case has its own PinnedRuntimePreflightBlockedError instead of overloading PinnedRuntimeInstallError.step.
  • serviceLauncherClient.ts reads launcher context from HostProcessEnvironment / a Context.Reference seam rather than the global process, and its error uses a multi-value operation discriminator with an optional cause.
  • Sqlite.ts now takes the trial flag from ServiceLauncherClient instead of re-decoding process.env.
  • server.ts constructs the launcher client once and shares one layer with both the runtime services and the routes layer.
  • Test modules import the services under review as namespaces (BootService, ServerSelfUpdate, ServiceLauncherClient).

Test-coverage concerns raised in an earlier run (deleted bootService activation-rollback/uninstall/unsupported cases and selfUpdate rejection branches) still stand in the diff but were not re-posted to avoid duplicate comments.


🔍 Agent activity — 1 tool call

💭 I'm checking the current state of the PR against the head commit to see what's changed since earlier runs flagged issues at different commits. The diff I'm looking at differs from what was shown before—some files like serverShutdown.ts that existed in prior commits are no longer in this diff, while selfUpdate.ts has been modified. Looking at the key candidates in the current diff, particularly ServiceLauncherClientError in the serviceLauncherClient.ts file, I see they've implemented the multi-value operation discriminator that was suggested before. However, the error's message is still derived from a free-form reason string rather than from stable structural attributes, which goes against the convention of deriving messages from the error's discriminator. The prior comment suggested either separate tagged errors per failure or a single error with a multi-value discriminator—they've gone with the latter, but the message handling still feels like it violates the principle of using structured data for messages. Looking at the rest of the implementation, the ServiceLauncherHostProcess is a Context.Reference with a defaultValue that reads the global process object, which is a seam. The ServiceLauncherClient itself is also a Context.Reference with a defaultValue, and in server.ts it's created via ServiceLauncherClient.make() within makeServerLayer, then wrapped in a Layer.succeed and provided to both runtimeServicesLive and routes layer—that's a single instance, which is correct. But the service module is missing the canonical layer export that the convention calls for; instead, server.ts is constructing the layer directly. Since ServiceLauncherClient is a Context.Reference with a defaultValue, technically no layer is strictly needed for the default case, and the prior run apparently had a ServiceLauncherClient.layer that's now been removed. It's borderline whether this is a violation—probably not worth flagging. Now checking selfUpdate.ts at the head to see the import style and verify the failWith signature. Looking at the diff, failWith is being called with the error reason and the full error object as a cause, which appears to be fixed. Moving on to bootService.ts, where writeDurably is using NodeFSP directly inside an Effect service instead of the available FileSystem.FileSystem service—it's calling Effect.tryPromise with node:fs/promises and computing the directory path manually with filePath.slice(0, Math.max(filePath.lastIndexOf("/"),1)) rather than using path.dirname. This bypasses the Effect environment and the acquired FileSystem service, which could be a convention violation, though the fsync requirement isn't available in the standard Effect FileSystem anyway. There's also a pre-existing writeFileStringAtomically helper somewhere that might be relevant. I should check if atomicWrite.ts handles fsync to see if there's actual duplication here, and I'm also noticing process.pid being used as a global. In pinnedRuntime.ts, the Effect.catchTags call with PinnedRuntimeInstallError looks correct now, but I need to verify the input.validate logic in ensurePinnedRuntimeInstalled. The PinnedRuntimePreflightBlockedError has version and reason as a string, where the reason is a free-form message from the child process — it's borderline on the convention of using structured attributes instead of unstructured messages, but since it's a distinct error they created after feedback, that's acceptable. Now looking at serviceProtocol.ts and its hand-rolled decoders... The serviceLauncher.ts bundle can only depend on Node builtins, so Schema isn't available there — that's why serviceProtocol.ts uses hand-rolled decoders shared between them, which makes sense. In servicePreflight.ts, there's a sync function that does IO directly (opening sqlite) and gets called eagerly in the CLI handler with JSON.stringify, which is a synchronous side effect happening outside Effect without wrapping it in Effect.sync — that's a convention violation at the dependency/runtime boundary. not throwing, though it does perform side effects during effect construction which is borderline for the conventions. servicePreflight.ts intentionally uses the sqlite driver directly rather than Effect services since it's read-only in a separate process. Then serviceLauncher.ts is a plain TypeScript class with promises rather than Effect — a deliberate design choice for a standalone bundle, and the tests bridge that boundary using Effect.promise. Moving on, serverActivation.ts uses a Context.Reference that can be undefined, and ProviderCommandReactor branches on that undefined case, which is acceptable. The persistence/Sqlite.ts layer now properly uses ServiceLauncherClient in its unwrap, fixing an earlier issue. But there's an import style inconsistency I'm noticing — Sqlite.ts imports ServiceLauncherClient as a named import, whereas the convention at service boundaries is to import the module as a namespace and access the service through it. Other files like server.ts and serverRuntimeStartup.ts follow this pattern correctly, so Sqlite.ts line 10 stands out as a violation that should be aligned with the rest of the codebase. Looking at bootService.ts, it imports from @t3tools/shared/hostProcess with named imports, which seems acceptable since it's a package subpath with references that were already using named imports before. Then in serverRuntimeStartup.ts, the make function now accepts options with activate and awaitAuxiliaryParked as callback strategies passed through configuration rather than as service instances. In server.ts, I'm seeing Layer.succeed calls for both ServerActivation and ServiceLauncherClient — the client is constructed once during layer unwrap with closures over the host and context, registering listeners per exchange, which should be fine since the effect runs once per layer build. Looking at the test imports now: bootService.test.ts imports the full module, selfUpdate.test.ts and serviceLauncherClient.test.ts do the same with protocol constants where needed, while serviceLauncher.test.ts imports specific exports since it's testing a plain class rather than a service module. The prior runs flagged missing test coverage for things like bootService install rollback and selfUpdate branches, so I need to check whether the backend behavior changes warrant updating those tests. The diff shows substantial test deletion with minimal replacement, and since those comments were already posted, I should avoid duplicating them unless the code changed in response. Let me verify the current state of those files and check whether the Sqlite.ts named import issue is the main new finding to flag. I'm spotting an import issue in Sqlite.ts where the service tag should be imported as a namespace rather than a named import at the service boundary, which violates the convention. I'm debating whether this is too pedantic to flag, though the guidelines are pretty explicit about how service modules should be consumed. For the fail helper in serviceLauncherClient.ts, it's a thin wrapper around error construction that the convention explicitly discourages — it's essentially just (...args) => new SomeError({...args}). The helper does normalize the optional cause field, which adds some real behavior, but it's still primarily functioning as a constructor alias that gets called repeatedly throughout the code. The convention says to construct errors at the failure boundary rather than through helpers like this, so I should flag it. For bootService.ts, writeDurably is directly using Node's filesystem API and appears to duplicate functionality that already exists in atomicWrite.ts on the server side — if that module provides the same durability guarantees, this is unnecessary duplication that bypasses the FileSystem service abstraction. And in servicePreflight.ts, there's synchronous SQLite I/O happening during effect construction, which violates the async boundary — the command handler is invoking runServicePreflight synchronously and then piping the result through an Effect, but the I/O should be wrapped in the effect itself, not executed eagerly. I'm noticing that writeDurably in bootService.ts is sidestepping the FileSystem and Path services that the module already has access to, instead reaching for Node's built-in fs/promises and hardcoding path separators. There's already an writeFileStringAtomically function in atomicWrite.ts that properly uses the FileSystem service, though it's missing fsync support—so the better approach would be to align with the existing pattern of acquiring dependencies from the environment rather than hiding them in globals. The helper can't access the path service at module level, so I'd need to either pass it as a parameter or move the logic inside the make function. That's a concrete fix. The string slicing approach is sketchy enough to flag as a brief note. Now I should check the remaining files—selfUpdate.ts, pinnedRuntime.ts, serverRuntimeStartup, and server.ts—to see if there are other issues. Looking at the failWith helper in selfUpdate.ts — it's a thin wrapper around the ServerSelfUpdateError constructor that now properly accepts and passes through a cause parameter, though it's still fundamentally just an error-constructor alias that was flagged in a previous review for being too minimal. The mapError on lines 165-169 is discriminating on the _tag field to handle the PinnedRuntimePreflightBlockedError case, but the convention recommends using Effect.catchTags instead of string-based tag comparison in mapError. Additionally, the wrapper message is being derived from error.reason, which is a structured field on the blocked error rather than a free-form cause message, so that part aligns with the guidance about deriving wrapper messages from stable structural attributes. The mapError with _tag comparison is idiomatic for mapping union types and doesn't warrant a flag — catchTags is for recovery, not transformation. Moving on to pinnedRuntime.ts, where ensurePinnedRuntimeInstalled receives runner as an explicit service parameter rather than pulling it from context. Looking at ServiceLauncherClient construction in server.ts and whether the layer export convention is being followed — the module doesn't export a canonical layer, so server.ts builds Layer.succeed inline instead. The author removed the duplicate layer export after feedback, but now consumers are hand-rolling the layer construction, which deviates from the standard pattern of exporting a canonical layer. The convention explicitly requires exporting a canonical layer, and since this service module doesn't have one, that's a legitimate finding—though I'm hesitant to re-flag it given the prior feedback that suggested removing nested Layer.provide calls. But exporting the layer and providing it once at the top level aligns with that earlier suggestion about composing cleanly. The question is whether server.ts actually needs the launcherClient instance directly beyond what the layer provides, so I should check how it's being used in that file. I'm checking whether ServiceLauncherClient.make() needs the ServiceLauncherHostProcess or HostProcessEnvironment contexts — both have defaults so there's no requirement there. The key issue is that make() can throw a ServiceLauncherClientError, and since it's inside the Layer.unwrap effect, that error will propagate up through the layer. Now I'm tracing where ServiceLauncherClient gets provided to satisfy the dependencies in makeRoutesLayer and Sqlite.layerConfig. The launcherLayer is provided via Layer.provide(launcherLayer) at line 618, which applies to the entire composed layer stack. But since ServiceLauncherClient is a Context.Reference with a default, if it's not explicitly provided somewhere, it silently falls back to that default instead of failing — which is risky for a stateful service that should only have one instance, especially since the default behavior could cause migrations to run in trial mode when they shouldn't. I'm checking the diff for any newly added parameter passing between services. The serverRuntimeStartup.make(options) and bootService.make({host}) calls look fine — they're passing Effects and plain config respectively. There's a behavior change in ProviderCommandReactor.ts with Cause.hasInterruptsOnly that's outside the scope here. For the findings to report: the named import of the local service module tag in Sqlite.ts:10 at a service boundary should be a namespace import instead — that's a concrete convention violation. And in bootService.ts, the writeDurably function bypasses the acquired Path.Path and FileSystem with manual string slicing and Node builtins, duplicating logic from atomicWrite.ts — that's a hidden runtime dependency issue worth flagging. The path slicing logic (filePath.slice(0, Math.max(lastIndexOf("/"),1))) works correctly on POSIX systems, but it's fragile and Linux-specific. I should frame this as a convention issue: the service already has path in scope, so it should use the acquired Path service with path.dirname instead of hardcoding separators. I'll also mention that writeFileStringAtomically already exists as an alternative. This falls within the remit since it's about dependency acquisition — using Node builtins where a service is already available violates the convention. The check defaults to failure, so I should focus on concrete violations. Finding 1 is solid. Finding 2 is borderline but worth including as a minor inline comment. Let me also double-check the CLI handler in servicePreflight.ts for sync IO patterns. Looking at the handler, runServicePreflight performs sqlite IO eagerly during effect construction rather than suspending it in an Effect wrapper — while this happens at the CLI boundary where it's more acceptable, best practice would wrap it with Effect.sync to properly manage failures and interruptions. I'll add a brief note about this. I'm second-guessing whether to flag the Effect.asVoid on Console.log since it's already returning void — that's just a style preference, so I'll drop it. Let me verify that servicePreflight.ts is only imported for its pure decoder helper, which is fine. But I'm noticing something more substantial: the codebase has a preferSchemaOverJson diagnostic, yet serviceProtocol.ts exports hand-rolled decoders and selfUpdate.ts uses JSON.parse without a suppression comment around line 134-144. Other parts of the PR added @effect-diagnostics-next-line preferSchemaOverJson:off comments, so if the rule is active, this would fail linting — though that might not be my concern if the rule only targets specific patterns. The real question is whether decoding untrusted child-process output with hand-rolled type guards instead of Schema.decodeUnknown in an Effect service violates the repo's conventions, even if the instructions don't explicitly require it. For the findings, I'm looking at the Sqlite import and the writeDurably path handling. The writeDurably helper duplicates logic from atomicWrite.ts and reimplements directory derivation with a hardcoded "/" separator, which seems worth flagging alongside the dependency hiding issue with process.pid as a module global. I need to pinpoint the exact line numbers for comments: Sqlite.ts line 10 where serviceLauncherClient.ts is consumed at the service boundary, and bootService.ts lines 145-171 for the writeDurably block. The comment for Sqlite.ts should note that since ServiceLauncherClient is used at a service boundary like other consumers in this PR, it should follow the same namespace import pattern. For bootService.ts, the issue is that writeDurably re-derives the parent directory by slicing on a literal / when make already has access to Path.Path — it would be cleaner to pass the path directly or define the helper inside make and use path.dirname, leaving only the fsync operation in the outer function. I should also flag the duplication with atomicWrite. For the check result, this is a clear convention violation so failure is the right call, consistent with how these checks have run before. Let me submit the review.

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


Agent Credits: 176 credits