Skip to content

refactor(server): simplify remote update handoff

MacroscopeApp / Macroscope - Effect Service Conventions failed Aug 1, 2026 in 5m 0s

Effect Service Conventions: 2 issues found

Two findings, both in apps/server/src/cloud/bootService.ts:

  • Line 286 — the ensurePinnedRuntimeInstalled failure is collapsed into the generic BootServiceInstallError (Could not set up the T3 Code background service.), discarding the structured step/exitCode/output-length context that the previous mapping preserved via BootServiceCommandError and that t3 service install surfaces to the user.
  • Lines 145–171 — the new writeDurably helper performs the launcher/state/unit writes with global node:fs/promises and a hand-rolled dirname instead of the FileSystem/Path services make already acquires, so the filesystem dependency is no longer represented in the Effect environment and atomicWrite.ts's writeFileStringAtomically is duplicated.

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.

Previously reported items verified as addressed at this head and therefore not repeated: namespace import of serviceLauncherClient in selfUpdate.ts; Effect.catchTags for the single recovered tag in pinnedRuntime.ts; ServiceLauncherClientError given a multi-value operation discriminator; the blocked-preflight case split into PinnedRuntimePreflightBlockedError instead of being encoded in PinnedRuntimeInstallError.step; launcher context acquired via HostProcessEnvironment/ServiceLauncherHostProcess rather than the global process; Sqlite.layerConfig reading trial from the ServiceLauncherClient service; and the launcher client now constructed once in makeServerLayer and shared with both runtimeServicesLive and the routes layer. Test-file findings from the prior run (named make imports at service boundaries, deleted bootService/selfUpdate branch coverage) are unchanged and were not re-posted.

Files examined beyond the diff: apps/server/src/atomicWrite.ts, apps/server/src/cloud/pinnedRuntime.ts, apps/server/src/cloud/selfUpdate.ts, apps/server/src/cloud/serviceLauncherClient.ts, apps/server/src/persistence/Layers/Sqlite.ts, apps/server/src/server.ts, apps/server/src/serverRuntimeStartup.ts, plus effect/unstable/cli import usage across apps/server/src/cli (consistent with existing repository style, not flagged).


🔍 Agent activity — 1 tool call

💭 I'm comparing the current diff against previous findings from earlier commits to see what's been fixed and what new issues might exist. Looking at the file changes, I can see that selfUpdate.ts now properly imports the service launcher client, which addresses an earlier concern. I'm checking through the rest of the modified files to understand the current state. The serviceLauncherClient.ts now uses a multi-value discriminator with operation: Schema.Literals([...]) which aligns with the prior suggestion, though the error still derives its message from a free-form reason string rather than structured attributes — this was flagged before but arguably addressed by the multi-value discriminator approach. Moving on to bootService.ts, I'm examining how the writeDurably helper interacts with the filesystem. Tests shouldn't be re-flagged since they were already caught in the prior run. For non-service modules like servicePreflight.ts and serviceProtocol.ts, named imports are fine. Now I need to check what's actually changed in the current head across key files like serverActivation.ts, serverRuntimeStartup.ts, Sqlite.ts, and server.ts to identify new findings. Sqlite.ts looks like it addressed the prior issue by using yield* ServiceLauncherClient in Layer.unwrap, but I need to verify whether the named import of ServiceLauncherClient is still problematic. In server.ts, the double-provided launcher layer issue appears resolved — launcherClient is constructed once via ServiceLauncherClient.make() inside Layer.unwrap, which is fine. But I'm noticing ServiceLauncherClient is defined as a Context.Reference with a defaultValue rather than Context.Service with an inline interface, which might be a pattern deviation worth examining. So the current serviceLauncherClient.ts file has the Context.Reference definitions and a make export, but no layer export — instead, consumers are manually wrapping it with Layer.succeed in server.ts. The convention seems to be that the service should export the layer directly rather than requiring consumers to construct it themselves. The Layer.succeed approach in server.ts is actually the right call here—the client needs to be constructed once and shared across multiple sub-layers, so injecting the already-built instance is the idiomatic pattern rather than exporting a separate canonical layer. I should pull the actual files to verify the current state of serverRuntimeStartup.ts, serverActivation.ts, and how Sqlite.ts is being imported rather than relying on the diff alone. I'm noticing something in bootService.ts though—writeDurably is using Effect.tryPromise with the node:fs/promises API directly instead of leveraging the FileSystem service that's already available in the Effect context. That's a hidden imperative dependency that bypasses the service abstraction. There's also an older helper writeFileStringAtomically in atomicWrite.ts that selfUpdate.ts used to call, but bootService reimplements this logic with raw Node APIs instead. That's duplication, and the error handling loses path context when it wraps the error in BootServiceInstallError. Let me check the atomicWrite definition and whether the test coverage changes I noted earlier still hold up. I'm noticing that bootService.ts introduces a writeDurably function that circumvents the acquired FileSystem and Path services by directly importing from node:fs/promises, using a manual dirname implementation with filePath.slice(...) that's POSIX-specific and potentially buggy for non-standard separators (though the service is Linux-only anyway), and this approach loses path context in error messages. I need to check whether this violates the convention about not hiding dependencies in module globals or singleton service closures. The real issue is that tests providing a fake FileSystem wouldn't intercept these direct node:fs calls—only the real NodeServices setup would work. The module already has access to FileSystem.FileSystem and Path.Path, plus there's a repo-level writeFileStringAtomically helper available. The justification for using raw node is the fsync durability requirement, since FileSystem doesn't expose synchronous operations, and the codebase even added a @effect-diagnostics nodeBuiltinImport:off annotation. Meanwhile, serviceLauncher.ts legitimately uses node builtins for its standalone bundle, so the pattern isn't entirely forbidden. The error handling also looks questionable—BootServiceInstallError({ cause }) passes only the cause with a generic message, which might violate the convention about preserving real causes without inventing redundant singleton fields. The filePath is available at the wrapping site and represents genuine context, but BootServiceInstallError's schema only accepts cause. Since the error class predates this change and is used uniformly throughout with the same pattern, flagging it might be overreach—the convention says to focus on violations introduced or retained in the pull request's scope, and this is a pre-existing pattern. The real question now is what changed between the last reviewed commit and the current one, so I need to compare those two states to identify what's actually new. The prior commit was likely rebased away, so I can't generate a diff. I'm reviewing the full current diff instead and tracking which issues I've already flagged to avoid repeating them—the named import of make in bootService.test.ts and coverage removals in both bootService.test.ts and selfUpdate.test.ts are still present but already noted. Looking through the current changes, most of the prior concerns have been addressed: selfUpdate.ts now properly passes the cause to failWith, pinnedRuntime's catchTags issue is resolved, the namespace import for serviceLauncherClient is fixed, PinnedRuntimePreflightBlockedError has been added, serviceLauncherClient now uses HostProcessEnvironment and ServiceLauncherHostProcess Context.Reference instead of global process/env, ServiceLauncherClientError has proper structured operations, Sqlite.ts correctly uses the ServiceLauncherClient service, and server.ts avoids the double-layer issue with a single instance via Layer.succeed. Now I'm checking for new issues—I notice Sqlite.ts has a named import of ServiceLauncherClient at a service boundary, which looks like it might be another instance of the same pattern I've been flagging. The module could export a canonical layer using Layer.effect with a shared instance, which would handle memoization within a single build — but I'm realizing the sharing concern might be overblown since Layer memoization would naturally handle that across multiple consumers. Looking at the other files: serverRuntimeStartup.ts exports a layer from layerWithOptions() and imports the ServiceLauncherClient module, which is fine. The make function there evolved from a constant to one accepting options, which aligns with the conventions. In serverActivation.ts, there's a ServerActivation Context.Reference holding an Effect that can be void or undefined. Moving through the remaining files, selfUpdate.ts uses Effect.fn with a generator function and now references ServiceLauncherClient for resolving the self-update capability. Then servicePreflight.ts is a pure synchronous function using node:sqlite with manual decoding instead of Schema — I'm noticing the repo has disabled the preferSchemaOverJson diagnostic in certain places, and serviceProtocol.ts also does manual decoding rather than relying on Schema, which might be a convention violation depending on how strict the error-handling checks are. Actually, looking closer, the manual decoders aren't errors themselves, and serviceProtocol.ts is intentionally kept dependency-free since it ships in the standalone launcher bundle with only Node built-ins — that's documented in the serviceLauncher.ts header, so the manual decoding approach is justified there. In cli/servicePreflight.ts, there's a command handler that calls a sync function which can throw, but runServicePreflight catches those exceptions, and the effect is properly voided. Now moving into ProviderCommandReact... The ProviderCommandReactor.ts change replaces Effect.failCause(cause) with Effect.interrupt — it's a behavior change but doesn't violate service conventions. In server.ts, the HTTP middleware uses Effect.orDie on a typed startup failure at the boundary, which isn't strictly a service-convention violation. But bootService.ts has writeDurably bypassing the FileSystem service entirely — that's a real candidate issue under the rule about not hiding dependencies in module globals or closures over singleton services. The pattern is suspicious: bootService uses the injected fs service everywhere except for this durable write, which calls raw Node APIs with process.pid. There's already writeFileStringAtomically in atomicWrite.ts using the FileSystem service properly, and selfUpdate.ts used to call it before this PR removed that usage. Now bootService is a third copy of the same logic, creating an imperative bridge inside an Effect service where the dependency is already available — that's exactly what the convention check should catch. The real issue is that writeDurably bypasses the FileSystem layer entirely, so tests can't intercept it, and the path manipulation uses filePath.slice(0, Math.max(filePath.lastIndexOf("/"),1)) instead of the Path service that's already there. The fix would be either reusing writeFileStringAtomically or wrapping the fsync durability requirement behind a small injected service so the dependency stays visible to the Effect environment. So the test coupling is real—bootService.test.ts relies on the actual filesystem rather than a fake one, which means it's genuinely dependent on the environment. I'll note this as a suggestion. Looking at pinnedRuntime.ts, I notice ensurePinnedRuntimeInstalled takes fs, path, and runner as explicit input parameters instead of pulling them from the environment—the function signature shows these are passed in directly. This matches the exact pattern I'm flagging: production services should acquire Effect dependencies from the environment using yield* Foo.Foo, not accept implementations as constructor arguments. The tests confirm this by passing fs, path, and a successfulRunner mock, and bootService does the same. But I need to check if this was already the case before the changes—the old version of pinnedRuntime.ts also took these parameters in its input. Looking at the diff, the removed code shows runner.run(...), fs.remove(...), and the type had readonly fs: FileSystem.FileSystem; readonly path: Path.Path;. So this pattern existed before and hasn't changed except for the addition of validate. Since ensurePinnedRuntimeInstalled is just a module-level helper function (not a service make), not a Context.Service, the convention doesn't really apply here—it's about service construction specifically. And since this was pre-existing and unchanged in signature, prior runs didn't flag it either. I'll move past this one. For serviceLauncherClient.ts, the ServiceLauncherHostProcess Context.Reference with defaultValue reading global process follows the standard host-reference pattern, same as what's in shared. That's fine. Now looking at ServiceLauncherClientError: it has operation with multiple literal values, reason: Schema.String, and an optional cause. The message is derived from the reason string—reason IS the message. There's a discriminator on operation too. The prior run actually suggested this exact design with a multi-value discriminator, so the author followed that guidance. But the conventions also say the message should be derived from the operation itself plus structured fields, not stored as free-form text. Looking at the call sites: "unmanaged" appears twice with the same string, decode-context has a fixed string, version-mismatch has a templated string with childVersion/currentVersion. Meanwhile "rejected" carries genuinely dynamic data from the launcher's IPC response. This is a partially-addressed prior finding—flagging the same error class again risks being repetitive, but the core issue remains: either separate tagged errors per failure mode, or keep the one error with a multi-value discriminator but derive the message from operation plus structured fields rather than storing arbitrary reason text. The author chose option 2 but kept reason as the message payload, which is acceptable enough that I won't re-flag it. Now I need to check selfUpdate.ts to see how it's structured and whether it uses a layer pattern with failWith for error construction, then look at server.ts, Sqlite.ts, and serviceLauncherClient.ts to confirm the patterns. Looking at the error handling in selfUpdate.ts — the blocked preflight case uses failWith to pass the reason from a structured error, which is now properly tagged, while ServerSelfUpdateError handles the contract error with a free-form reason field. For Sqlite.ts, I'm using a named import for ServiceLauncherClient to match the existing pattern with ServerConfig. The convention flags aliases that erase module namespaces, but a plain named import of the tag itself is only a partial violation. Since the file already follows this style locally and this is just one new line, I don't think it's worth flagging — the check defaults to failure and I should focus on real issues rather than inventing violations. Let me examine serviceLauncherClient.ts and serverRuntimeStartup.ts more closely for actual problems. I'm noticing that the make function uses a local fail helper that just wraps error construction with optional cause branching, which violates the guideline against adding helpers that only construct errors. The same issue appears to be present in failWith. Looking at selfUpdate.ts, it seems failWith is pre-existing and was restored to accept a cause parameter. The fail helper in serviceLauncherClient is new and used about eight times—it's a thin factory that branches on whether cause is present, which technically might be unnecessary if Schema.optional allows undefined values directly, but the branching could be justified by TypeScript's exactOptionalPropertyTypes. It's borderline whether this warrants flagging since the same pattern already exists elsewhere. On the layer export: serviceLauncherClient deliberately omits it, requiring consumers to construct and share a single layer instance manually. The server does this by creating the launcher client once and providing that same layer to multiple dependents. It's a convention gap—normally you'd export the canonical layer—but the design is intentional and acceptable for ensuring a shared singleton. As for using Context.Reference instead of Context.Service: that's the repo's pattern for optional services with default no-op implementations. Several modules like ServerActivation and HostProcess follow this same approach, so it aligns with established conventions even though the standard order would use Context.Service with an inline interface. Now I'm checking serverRuntimeStartup.ts to see if it properly acquires the launcher from the environment and handles the activation options correctly. I'm tracing through how ServiceLauncherClient.make() gets invoked within the Layer.unwrap effect in makeServerLayer — its dependencies resolve automatically since they're references with defaults, and the error type propagates correctly. But I need to think through the scope implications of calling this inside the unwrap effect... The make function returns a plain object without any special scoping requirements, so that's straightforward. The listeners get registered lazily during each exchange call, not upfront, so there's no scope issue there either. Now I'm checking whether ServerSelfUpdate.layer properly receives ServiceLauncherClient — it's satisfied through the launcherLayer that gets provided to makeRoutesLayer in server.ts, and ProcessRunner.layer is also provided inside selfUpdate's own layer, so that chains correctly. But I'm concerned about a silent-default risk: if ServerSelfUpdate.layer requires ServiceLauncherClient as a Reference with a default, other compositions like MCP or tests could accidentally satisfy it with the default instead of an explicit layer. And looking at Sqlite.ts, its layerConfig also requires ServiceLauncherClient as a Reference with default, which means if the launcher layer isn't explicitly provided to the persistence layer, it silently falls back to trial=false — I need to verify whether server.ts actually provides it there. The Reference-with-default pattern hides the requirement from types, which arguably violates the convention that production service dependencies must be exposed in their types — but References are an accepted idiom across the codebase, and prior review already covered this area, so I'll skip flagging it. Now I'm scanning the diff for clear convention violations, starting with the CLI imports. There's also the writeDurably function in bootService.ts that could be a candidate. Let me verify how the CLI imports are structured across the codebase. This aligns with the repo's patterns, so it's not a real issue. Now I'm shifting focus to the writeDurably finding to see if there's anything more substantial there, and I want to check the rest of bootService.ts—specifically the install, status, and uninstall methods—to understand the conventions being used. I'm noticing a convention issue in how the bootService.ts install function handles errors. It's collapsing a structured PinnedRuntimeInstallError that contains step and exit code information into a generic install error with a generic message about setting up the background service. The previous approach was more granular—it mapped install-step failures to BootServiceCommandError while preserving those details, and handled non-install failures separately. The problem is that BootServiceError already includes BootServiceCommandError with step and exitCode fields, which is exactly what the pinned runtime error carries. By wrapping everything in a generic error, the caller-visible message loses specificity like "failed while installing the pinned t3 runtime (exit code N)"—and since the CLI surfaces these messages directly to users, this is a concrete regression in error clarity at a user-facing boundary. losing the step and exit code context that was previously mapped to BootServiceCommandError. The diff removed that error branching logic, so install-step failures now collapse into a generic BootServiceInstallError without the structured context. Finding #2 is that writeDurably bypasses the injected FileSystem and Path services, duplicating atomic write logic and losing file path context in errors — if durability is needed, the effect's fs.open with sync support would be the proper approach within the service boundary. Now I'm checking whether effect's FileSystem File API has sync support and looking at how the repo currently uses it. I'm noticing there's a systemdSelfUpdate.ts file at the current head that doesn't appear in the diff, which suggests it might be a pre-existing module rather than something added by this PR. Let me trace through whether this file existed at the merge base to understand what's actually being changed with the selfUpdate code. There's a discrepancy between what git_grep found at that revision versus what the tree view is showing. Git_grep should default to the cloned commit (the PR head for reviews), but maybe it's actually searching a different revision, or the tree view is resolving to the default branch head instead. I should try running git_grep with an explicit revision to clarify which commit is actually being searched. The systemdSelfUpdate.ts file exists unchanged at the merge base, so it's not relevant to this diff. Now I'm identifying the key findings to document, starting with how bootService.ts at line 286 is collapsing the structured PinnedRuntimeInstallError into a generic BootServiceInstallError, which strips away the step information, exit codes, and user-facing messages that were previously preserved. Looking at the error handling convention here: PinnedRuntimeInstallError isn't part of the BootServiceError union, so it needs to be wrapped—but the wrapping should maintain structural context by mapping it to BootServiceCommandError with the step details intact. The previous code did exactly that, so this is a violation of the pattern "Map failures where that context is known instead of wrapping an entire multi-step pipeline in one generic error." For the second finding around writeDurably, I'm checking whether using imperative Node fs directly inside the service—bypassing the injected FileSystem/Path abstraction—actually violates the dependency convention about not hiding dependencies in module globals or closures. The service declares a FileSystem requirement but the actual writes go through the global node API, making that dependency invisible. There's also a correctness issue with the manual path slicing instead of using the injected path.dirname, though the service is Linux-gated so it might not manifest. I'll frame this as a suggestion to use the injected abstractions and the existing writeFileStringAtomically helper instead of the global APIs, keeping it concise. The PinnedRuntimePreflightBlockedError structure with version and reason fields works fine—the reason is dynamically sourced from the preflight child process, so it can't be derived statically, which is acceptable. This approach was already flagged in prior review. Now I'm weighing the main finding: the bootService install error mapping has regressed from structured to generic messaging. The old code preserved specific context like exit codes and step information in user-facing messages, but the refactor wraps everything under a generic "Could not set up the T3 Code background service" message. The BootServiceInstallError does maintain the cause chain via Schema.Defect(), but we've lost the granular error details that were previously logged. The fix would be mapping PinnedRuntimeInstallError to BootServiceCommandError while preserving the step, exit code, and stderr information. Writing the error mapping... With exactOptionalPropertyTypes enabled, passing the optional exitCode field should work fine since the schema allows undefined values. For the inline comment, I need to target line 286 in the file—that's where the original error mapping line appears in the diff hunk, and it's definitely part of the changed section. Now looking at the writeDurably function added at lines 145-171. The issue is it's using the global node:fs/promises and manually computing the directory path instead of leveraging the FileSystem and Path services that the make function already provides. This means the service loses its filesystem dependency from the Effect environment, making it impossible for tests to substitute a mock FileSystem layer, and there's also duplication of the atomic write logic. The suggestion would be to perform the write through the Effect FileSystem service instead, which should have a sync method available for ensuring durability. I'm deciding whether to fail the check based on the findings—there's a moderate error-context regression and a dependency-acquisition deviation that qualify as convention violations. I'll post the review with inline comments at the specific line numbers where these issues occur.

1. modify_pr — 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.


Agent Credits: 257 credits