Repository navigation
Effect Service Conventions: 2 issues found
Two error-modeling convention violations, both in the new apps/server/src/cloud/serviceLauncherClient.ts:
ServiceLauncherClientErrorderivesmessagefrom a free-formreasonfield instead of its structural attributes, and itsoperationdiscriminator is used byselfUpdate.tsto choose the caller-visible message (operation === "rejected"). The launcher-rejection case should be its own tagged error, with the remaining error's message derived fromoperation.- The module-level
fail(...)helper's only behavior is constructingServiceLauncherClientError, hiding the error's attributes and cause behind positional arguments at each failure site.
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.
Reviewed scope: apps/server/src/cloud/{bootService,pinnedRuntime,selfUpdate,serviceLauncherClient,serviceProtocol,servicePreflight}.ts, apps/server/src/{serverActivation,serverRuntimeStartup,server}.ts, apps/server/src/persistence/Layers/Sqlite.ts, apps/server/src/environment/ServerEnvironment.ts, the orchestration/relay forkParked call sites, and the accompanying tests.
Items verified as conforming (several were findings in earlier runs and are now addressed):
ServiceLauncherClientis now a realContext.Servicewith an exportedmakeand canonicallayer, composed once inserver.tsvia a single sharedlauncherLayerreference, soServerSelfUpdate,ServerRuntimeStartup, and the routes layer share one instance.bootService.makeacquiresFileSystem/Path/ProcessRunnerfrom the environment; the durable launcher/state/unit writes now go through those services rather thannode:fs/promises, and the file-widenodeBuiltinImportsuppression is gone.- Service modules are imported as namespaces at service boundaries (
ServiceLauncherClient,ProcessRunner,ServerConfig), with named imports retained only for pure helpers, errors, schemas, and constants. serverRuntimeStartup.layerWithOptionstakes callback/effect strategies, not service instances, and still exports the canonicallayer.serviceLauncher.ts/serviceProtocol.tshand-rolled decoders are justified by the standalone Node-only launcher bundle and are documented as such.
🔍 Agent activity — 1 tool call
💭 I'm checking the current PR state against previous runs to see which issues have been resolved and what violations still need addressing. The prior runs flagged problems with ServiceLauncherClient—it was declared as a Context.Reference with a fake default and no layer—but the current diff suggests this has been partially fixed with the class now properly extending Context. Service and exporting a layer. The bootService file also appears to have been updated, removing the problematic Context import in favor of HostProcess imports, which should address the nodeBuiltinImport violation. Now I'm scanning the current head for any new violations. I notice
servicePreflight.tsis a new module with synchronous functions that directly use node:sqlite without Effect, but since it's a preflight helper rather than a service, that might be acceptable. The hand-rolled JSON decoding indecodeServicePreflightResultis worth examining—there's a pattern in the repo for using@effect-diagnostics preferSchemaOverJson:offsuppressions, and similar hand-rolled decoders exist deliberately inserviceProtocol.ts. Looking atserviceLauncherClient.ts, the imports look fine—the contracts import is allowed, and Effect modules are properly namespaced. TheServiceLauncherHostProcessuses aContext.Referencewith a default value from the globalprocessobject, which follows the repo's established pattern for things likeHostProcessEnvironment. Now examiningServiceLauncherClientError—it uses a multi-valueoperationdiscriminator with areasonstring, but themessagemethod just returns that free-formreasonfield directly rather than deriving it from the structural attributes. This partially violates the convention about deriving messages from attributes instead of storing unstructured text, though it seems this was accepted in a previous iteration with some adjustments to how the error was constructed. Looking atselfUpdate.ts, it imports the service preflight helper and uses a namespace import for the launcher client. The error handling maps based on the operation type—if it's "rejected", it uses the error reason, otherwise it falls back to another value. This actually surfaces a real issue: theoperationdiscriminator is being used to select the user-facing message, which according to the guidelines suggests these should be split into separate error classes. That said, I need to be careful here since the previous review already flagged this and the author made changes in response, so I shouldn't keep re-raising the same concern unless there's a new angle. There's also the_tagstring comparison in the mapError call—using direct string matching instead of Effect.catchTags. The convention would be to use catchTags for statically known tagged failures, though here it's being used to translate both variants in mapError, which makes it a bit trickier to refactor cleanly. The newPinnedRuntimePreflightBlockedErrorclass withversionandreasonfields is actually what was suggested in the prior review, so that's the recommended fix already implemented. Now I need to look at the actual current files rather than just the diff—things like ServerActivation and how it's wired up in server.ts to see if there are other issues I haven't already flagged. Looking atServerActivationinapps/server/src/serverActivation.ts, it extendsContext.Reference<Effect.Effect<void> | undefined>with an undefined default value. This is a gate mechanism where undefined means "not activated"—similar to the fake DI pattern I flagged before where consumers silently get a fallback when the context isn't provided. The behavior differs:forkParkedimmediately forks when ServerActivation is present, but silently skips parking if the layer isn't provided in production. It's intentional for non-server contexts like tests and CLI, so it's not quite the same concern, but the "silently observe default" issue still applies—ifserver.tsdoesn't provide the activation layer, fibers start without the expected parking behavior. This could warrant a flag similar to the ServiceLauncherClient conversion, butContext.Referencewith a default value is a legitimate pattern used throughout the codebase (likeHostProcessPlatform), so I'm leaning toward not flagging it. Now looking atapps/server/src/serverRuntimeStartup.tswheremake(options?: StartupOptions)has anactivparameter that's cut off. TheStartupOptionsobject contains callback strategies likeactivate,abort, andawaitAuxiliaryParked— these are deliberate callback patterns rather than service instances, which is allowed. The file also exportslayerWithOptionsand a canonicallayerfunction, so that's fine. But inapps/server/src/persistence/Layers/Sqlite.ts, I'm seeingServiceLauncherClient.resolveServiceLauncherMode()being called insideLayer.unwrap, which re-reads the environment context instead of acquiring the service — this is the same pattern that was flagged before. The function got renamed and now returns{managed, trial}, but the underlying duplication persists. There's likely a dependency cycle preventing the Sqlite layer from using the launcher layer directly, but the previous suggestion was to acquire the flag from the service instead. Since the code changed but not in the direction suggested, I need to decide whether to re-flag it — the rule says not to post duplicate comments unless the code changed in direct response to what I flagged, and this feels like a partial change that didn't fully address the issue. The import fix in ServiceLauncherClient looks good. Now I'm checking bootService.ts to see how it's handling the error mapping — specifically whether PinnedRuntimeInstallError is being collapsed into BootServiceInstallError with the right error tagging. I need to look at the actual code to understand the current error handling strategy. Moving on to serviceProtocol.ts next to review the decoder implementation. I'm noticing thefailhelper in serviceLauncherClient violates the change discipline—it's just a wrapper that constructs ServiceLauncherClientError with optional cause, which is exactly the kind of single-purpose error constructor helper we're trying to avoid. I also seeEffect.die("string")being used, which isn't aligned with the conventions, and there's amapErrorcall in bootService install that got cut off. Looking at howPinnedRuntimeInstallInputis structured in pinnedRuntime.ts, I notice it takes filesystem, path, and process runner as explicit parameters rather than relying on service dependencies. This is a pattern where a standalone helper function accepts these implementation dependencies directly as arguments, even though they're typically owned by services. Both callers already have these dependencies available from their environment and pass them through. While this is technically dependency-passing rather than acquisition, it's not a new pattern introduced by this PR—the function signature existed in the merge base and was just refactored into an interface here. The question is whether to flag this as a violation since the helper could theoretically acquire these from the environment instead. Given that this is pre-existing design that the PR only reshaped, and I haven't flagged it in previous runs, I'm inclined to let it stand rather than flag a retained pattern that's arguably justified for a plain utility function. The docstring forensurePinnedRuntimeInstalledgot moved from the function to thePinnedRuntimeInstallInputinterface during the refactor—a minor misplacement that preserves the documentation but puts it in the wrong place. Also noticed the import ordering is slightly off (Path, Schema, Option, Semaphore), though that's a style issue rather than a convention violation. Looking atselfUpdate.tsnow, I see afailWithhelper that wraps error construction, which would normally be flagged as a violation. But checking the merge base, this helper already existed there, so it's pre-existing code that the PR retained rather than introduced. Theerror.operation === "rejected"discriminator pattern is interesting—it's using a discriminator to select the user-facing message, which the conventions suggest should use separate error classes instead. I actually recommended this discriminator approach in an earlier review, but looking back at the convention text, this is exactly the case it targets: semantically distinct failures like "launcher rejected with user-facing reason" versus other IPC failures should be split into separate error classes. The author went with a union-with-discriminator approach but kept the message generation flexible. I could flag this as a defensible issue—not a duplicate since the code changed in response to my prior feedback and still has this structural inconsistency. A lighter approach might be to focus on the clearerfailfactory helper violation and mention that a dedicatedServiceLauncherRejectedErrorwith structured reason would fit the pattern better, though I want to avoid ping-ponging on this. Let me scan the other new files like serverActivation.ts, serverRuntimeStartup.ts, server.ts wiring, ServerEnvironment.ts, and Sqlite.ts for clearer violations. The strictness issue is actually resolved sinceresolveServiceLauncherModenow consistently fails on invalid context throughresolveStartup. The real remaining issue is thatSqlite.layerConfigduplicates state the service already exposes, though there's a legitimate ordering constraint at play here. Looking at the layer composition in server.ts,launcherLayeris provided at the top level and flows down throughRuntimeDependenciesLive, soSqlite.layerConfigcould theoretically yield theServiceLauncherClientdirectly without creating a circular dependency. ButmakeRoutesLayeralso provideslauncherLayerseparately, which means the same layer instance gets provided in multiple places—and since Effect layers are memoized within a single build, I need to verify whether this creates duplicate instances or if the memoization handles it correctly. The key insight is thatresolveServiceLauncherMode()reads from the environment instead of yielding the service directly, which avoids constructing a second client with distinct IPC listeners. This matters becauseServerEnvironmentmight be built in a context where the launcher layer isn't yet provided, so using the environment variable is safer than assuming the service is available. Since I already flagged this duplication in a previous run and the code has evolved to use Effect refs with consistent strictness, re-flagging would just duplicate feedback. The remaining architectural choice—avoiding a second IPC client at a layer boundary—is defensible, so I'll move on to checkserverRuntimeStartup.tsandserver.tsfor how they use the service. I'm noticing that the diff removedEffect.forkScopedfrom bothkeybindings.startandserverSettings.start, which is a behavioral change rather than just a convention issue. If these are long-running effects, removing the fork could cause the startup to block waiting for them to complete, so I need to verify what these effects actually do and whether they're meant to run concurrently or sequentially. Looking at the keybindings.start behavior — it returns after establishing the runtime without looping, which is safe. The removal of forkScoped makes sense since start now blocks until completion, and while it was probably forked originally to avoid blocking, this change aligns with the parked design pattern. In ProviderCommandReactor, I'm noticing the shift fromEffect.failCause(cause)toEffect.interrupt— that's a behavioral change rather than a stylistic one. I'm also seeing that CheckpointReactor importsforkParkedfrom serverActivation.ts at the end of the imports list, which is just import ordering and not a convention violation. Moving through servicePreflight.ts, the CLI command uses plain synchronousConsole.log(JSON.stringify(...))which is fine, and serviceLauncher.ts is deliberately non-Effect with appropriate suppressions and comments. Now I need to decide what findings to report. Thefailhelper in serviceLauncherClient.ts is a clear violation — it's a newly introduced helper that just wraps error construction, which violates the rule against adding such helpers. It's used about eight times in the code, so there's a question of whether the rule's intent covers helpers used frequently versus once. The rule's rationale is to keep error construction visible at failure boundaries, but here the helper does make the calls fairly readable. The helper also handles optional cause parameters, which adds some utility. I'm torn on this one. The rule literally forbids helpers whose only behavior is wrapping error construction, andfailfits that description exactly. But I noticefailWithin selfUpdate.ts follows the same pattern and is pre-existing — I didn't flag it as a violation in prior reviews, which suggests the repo tolerates this pattern. Flagging the newfailwhile leaving the identicalfailWithunflagged would be inconsistent. It's risky either way, but given six prior runs with findings, posting one solid finding is reasonable. The second finding involves howerror.operation === "rejected"in selfUpdate.ts drives the user-facing message selection through a discriminator, which aligns with the convention's intent for caller-visible messages. The convention violation is real: sinceselfUpdatebranches on operation to select different user-facing text, the failures don't share semantics, so splittingrejectedinto its own error class aligns with the guidance I gave earlier. But I'm hesitant to fail the check again—this is the seventh run on the same PR and the design has already iterated multiple times. Repeatedly flagging new issues on the same file feels like poor reviewer practice, so I should scan for anything more clearly wrong before deciding whether to enforce this. Now I'm looking at the server.ts changes around the orchestration reactor and ServerEnvironment to check whether the launcher layer wiring still has duplication and whether ServerSelfUpdate gets its own launcher instance. I'm realizing thatlauncherLayeris being provided to bothruntimeServicesLiveandmakeRoutesLayer, but since it's the same Layer reference composed into a single graph, Layer memoization will handle it per-build based on reference identity rather than duplicating the work. I also notice thatServerSelfUpdate.layeris provided insidemakeRoutesLayerand depends onServiceLauncherClient, which gets satisfied by thelauncherLayerprovided at the top level — so there's a single instance throughout. Now I'm focusing on the remaining error-modeling candidates to decide whether to fail or pass, given that the code has been substantially reworked across the previous six runs in response to earlier findings. The two main candidates are thefailerror-factory helper and howServiceLauncherClientErrorconstructs its message from the free-formreasonandoperationfields. The second one seems more substantive — it directly addresses the guidance about deriving messages from attributes rather than storing unstructured text, and about splitting semantically distinct failures when a discriminator drives user-facing messages. The key insight is thatreasonisn't the only data (there's alsooperationandcause), and the launcher rejection genuinely needs to carry the external reason text. But the other seven operations use hardcoded sentences that could be derived fromoperationalone. So the cleaner approach would be to split out a separateServiceLauncherRejectedErrorthat carries the launcher's reason, while deriving the message for the remaining error from itsoperationfield. I'm deciding whether to flag both the error class and thefailhelper in separate comments, or combine them. Thefailhelper on lines 89-96 is a distinct issue — it's a factory that just wraps error construction, which violates the principle of keeping error attributes and cause visible at the failure boundary. But that's a separate rule from the message-derivation issue, so I'll include a brief secondary comment about it rather than trying to fold everything into one. I'm settling on two inline comments and marking the check as failed, since there are findings. Now I need to draft the first comment targeting the error class around line 35 where the message is derived from reason. The core issue is that one error type carries multiple operations, but the message just returns unstructured reason data while the caller branches on operation to decide what users see. I should suggest extracting the launcher's rejection into its own error class since that's the only case where the reason is authored elsewhere and exposed to users, then derive the remaining error's message directly from the operation type. The second comment targets thefailhelper around lines 89-96, pointing out that it only constructs the error and should be moved to the actual failure boundary instead. I need to be careful about line numbers since serviceLauncherClient.ts is new and all its lines appear in the diff, but I should verify selfUpdate.ts lines match too.
1. modify_pr — 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.
Agent Credits: 214 credits