refactor(errors): represent domain failures as Schema.TaggedError - #53
Open
FreshlyBrewedCode wants to merge 5 commits into
Open
FreshlyBrewedCode wants to merge 5 commits into
FreshlyBrewedCode wants to merge 5 commits into
Conversation
FreshlyBrewedCode
force-pushed
the
34-domain-errors-as-tagged-errors
branch
from
September 20, 2026 12:58
56d5ec3 to
b6ebdee
Compare
FreshlyBrewedCode
added this pull request to stack #58
September 20, 2026 13:00
This was referenced Sep 22, 2026
Convert the four domain failures from thrown Error subclasses matched by
instanceof to Schema.TaggedError with checked tag matching:
- DedupeKeyError: carries key and holderRunId fields
- ConcurrencyLimitError: carries maxConcurrentRuns field
- DispatchCapError: carries message field
- RunCancelledSignal: no fields (the run's own unwind signal)
Constructor signatures change from positional to struct args
(e.g. new DedupeKeyError({ key, holderRunId }) instead of
new DedupeKeyError(key, holderRunId)).
Add domainErrorMessage helper in run.ts to construct human-readable
messages for RunFailed events, since TaggedError.message is empty
in this Effect version when classes are loaded across module
boundaries.
Update runtime catch sites in run.ts to match on _tag instead of
instanceof, preserving the single-catch property of RunCancelledSignal.
Add tests verifying _tag, fields, and instanceof Error for all four
errors. Update existing tests to use new constructor signatures.
Update the HTTP layer, scheduler, and sample workflow to match domain errors by _tag instead of instanceof: - HTTP: 409 status mapping unchanged from a client's perspective; error messages now constructed from TaggedError fields - Scheduler: skip-vs-fail branch is now an exhaustive switch over all four domain error tags (ConcurrencyLimitError → skipped-concurrency, DedupeKeyError/DispatchCapError/RunCancelledSignal/unknown → fire-failed) - ready-sweep: dedupe collision check uses _tag matching - Add exhaustive match test in scheduler.test.ts verifying each domain error type is explicitly classified
…zed error tags (#34) The catch block matched on a plain, `as`-cast `_tag: string | undefined` with no `default` arm: an error whose tag was none of the four known ones matched nothing in the switch, so no `TickResult` was pushed for that schedule at all — a silent drop, worse than the wrong-bucket bug issue #34 set out to fix. Replace the cast with a real `instanceof`-narrowed union (`ScheduleFireError`) and a switch over the resulting literal `_tag` type, whose `default` arm is a `satisfies never` compile-time exhaustiveness check — so a tag added to the union without a corresponding case fails to typecheck instead of silently vanishing at runtime. Anything outside the union (a non-`Error` throw, or a future error type) still lands in `fire-failed`, never nothing. Add a regression test that throws an error with a tag the switch doesn't recognize and asserts it produces exactly one `fire-failed` result.
…classes (#34) The "concurrency limit reached..." and "dedupe key held..." strings were hand-written in up to six places (run.ts's domainErrorMessage and three call sites in http.ts), free to drift apart. DispatchCapError already avoided this by carrying its message as a field; give ConcurrencyLimitError and DedupeKeyError an overridden `message` getter (Schema.TaggedError's base only sets an own `message` property when a `message` field is passed, so the getter is free to take over) and have every call site read `err.message` instead of rebuilding the string. domainErrorMessage collapses to the generic Error fallback now that all three domain errors carry their own message. Also replace the remaining `_tag`-cast pattern with `instanceof` at the single-tag call sites in run.ts and http.ts (http.ts mixed both styles two lines apart) — only the scheduler's exhaustive multi-tag match needs tag-based dispatch. The produced strings are unchanged character-for-character (verified against the literals they replace), so the HTTP 409 responses are unchanged from a client's perspective.
…y-sweep (#34) The tag-matching pass on issue #34 replaced this single-tag `instanceof DedupeKeyError` check with an unsafe `_tag`-cast for consistency with the scheduler, but dropped the comment explaining why a dedupe collision here continues the loop rather than aborting it, and the workflow only ever needs to distinguish one tag — restore both.
FreshlyBrewedCode
force-pushed
the
34-domain-errors-as-tagged-errors
branch
from
September 23, 2026 07:09
4a93e1c to
dcaf696
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #32 · Closes #34
server/scheduler.tsdecided whether a missed cron window reads asskipped-concurrencyorfire-failedbyinstanceof-checking a caughtunknownagainstConcurrencyLimitError. That isa semantic decision resting on an untyped catch: a future error type thrown from the same path
would silently land in the wrong bucket, and a suppressed window would look like a failed one.
Schema.TaggedErroris already used twice in the codebase (SchedulerError,AgentStepChunkError), so this finishes that pattern for the daemon's four domain failures ratherthan introducing a new one.
What changed
ConcurrencyLimitError,DispatchCapError,DedupeKeyError(src/lib/dedupe.ts), andRunCancelledSignal(src/runtime/run.ts) areSchema.TaggedErrorclasses instead of thrownErrorsubclasses. Each keeps its existing data:DedupeKeyErrorkeepskeyandholderRunId;ConcurrencyLimitErrorcarriesmaxConcurrentRunsas a real field instead ofonly baking it into the message string;
DispatchCapErrorkeeps its formattedmessageas anexplicit field;
RunCancelledSignalstays field-less.server/scheduler.ts's skip-vs-fail catch intickOnceis now genuinely exhaustive andcompiler-checked: an
instanceof-narrowedScheduleFireErrorunion (the four domain classes,value-imported) feeds a switch over the resulting literal
_tagtype, whosedefaultarm is asatisfies nevercheck — a tag added to the union without a matchingcasefails to typecheckrather than silently dropping the schedule's result. Anything outside the union (a non-
Errorthrow, or a genuinely unrecognized future error) still produces exactly one
fire-failedentry,never zero.
ConcurrencyLimitErrorandDedupeKeyErrornow carry an overriddenmessagegetter, the samepattern
DispatchCapErroralready used for its field — the collision/limit strings are writtenonce, on the class, instead of being hand-duplicated at every catch site.
domainErrorMessage()inruntime/run.tscollapses to the genericErrorfallback now that allthree domain errors format their own message; every call site (
http.ts's three 409 handlers,run.ts) readserr.messageinstead of rebuilding the string inline. Output is verifiedbyte-for-byte identical to the strings it replaces.
ready-sweep.ts,run.ts,http.ts) useinstanceof DedupeKeyError/instanceof RunCancelledSignalinstead of the_tag-cast pattern — only the scheduler'smulti-tag exhaustive match needs to dispatch on
_tag.http.tsno longer mixes both styles inthe same catch block.
sample/workflows/ready-sweep.tsregained the comment explaining why a dedupe collisioncontinues the loop instead of aborting it, dropped in an earlier pass.
_tagcoverage for all four errors and a scheduler test per knownoutcome (
skipped-concurrency,fire-failedforDedupeKeyErrorandDispatchCapError), plusa new regression test that throws an error whose
_tagthe switch doesn't recognize and assertsit lands in
fire-failedwith exactly one result entry — the case the earlier version of thisbranch silently dropped.
Notes for reviewers
switchlists all four known tags byname, and the
defaultarm doesreturn err satisfies never, which only typechecks once everymember of
ScheduleFireErrorhas a precedingcase. If a fifth tagged error is ever added tothe union without a case, this fails to compile instead of reproducing the original bug.
error,dedupeKey,holderRunIdfields and status code) arebyte-for-byte unchanged from a client's perspective — checked by comparing the literal template
strings the getters replaced, character for character, and confirmed by the existing
http.test.tsassertions on
dedupeKey/holderRunId/errorcontinuing to pass unmodified.RunCancelledSignalis still only checked in two places inruntime/run.ts(a re-throw guard inthe write-back catch, and the terminal catch that emits
RunCancelled), matching the pre-existingstructure.
Verification
bun run check(format:check + lint + typecheck +bun test) passes: 293 tests across 35files, 0 failures.
ConcurrencyLimitError/DedupeKeyErrorand diffed.messageagainst theliteral strings previously hand-written at each call site — identical.
bun run test:e2e.Stack
33-parse-cli-with-effect-cli(issue Parse CLI arguments with effect/unstable/cli #33)34-domain-errors-as-tagged-errors(issue Represent domain failures as Schema.TaggedError #34) ← you are here35-move-chunk-interpretation-into-adapter(issue Move chunk interpretation into the agent adapter #35)37-move-headless-permissions(issue Move headless permission setup out of the workspace allocator #37)36-agent-runtime-service(issue Add an Effect composition root and make the agent runtime a service #36)38-move-singletons-into-layers(issue Move the daemon's remaining singletons into layers #38)Stack created with GitHub Stacks CLI • Give Feedback 💬