fix(triggers): stop-event, change-baseline, hung-poll and skip-log defects - #738
Merged
sroussey merged 5 commits intoAug 10, 2026
Conversation
`releaseWhenIdle` guarded the `_run` clear with an identity check but emitted `stop` unconditionally. A `stop()` that was not awaited followed by a `start()` therefore reported the trigger as stopped while the new generation was actively firing, so an observer latching on `stop` stops watching a live trigger. The emit now sits inside the same identity guard, after the clear, so a `stop` listener reading `trigger.running` still sees `false` on a real stop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PDvMMv78PuEw4T5atLeJeS
`runTick` advanced `state.previous` before invoking the handler, so a handler rejection consumed the change: the update it failed on was never offered again and the loss was silent, with nothing distinguishing "handled" from "dropped". The baseline is now restored when delivery throws, making a `firesOnChange` poller at-least-once. The retry always carries the freshest poll result — there is no backlog — and is paced by the poll period, so nothing accumulates. The restore is skipped when a later tick already advanced the baseline (reachable under `overlap: "concurrent"`), which would otherwise re-deliver a value that has since been handled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PDvMMv78PuEw4T5atLeJeS
…ging A poll with no deadline can hang forever — a `fetch` against a black-holed host is the usual way. The tick stays in flight, so under the default `overlap: "skip"` every later tick is dropped and the trigger goes silent with nothing on `error`; a hang is not a throw, so `errorBackoff` never engages either. `pollTimeoutMs` bounds one poll. Exceeding it aborts the signal handed to `poll` and fails the tick with a `TriggerPollTimeoutError`, which counts as a poll failure, so it is reported on `error` and drives `errorBackoff` like any other. The deadline is raced against the poll rather than merely signalled: a poll that ignores its signal never settles, and aborting alone would leave the tick in flight. Releasing the tick is also what un-wedges the skip branch and bounds how long `stop()` waits on a hung poll. The default stays `undefined` — no deadline — so a legitimately slow poll is not broken on upgrade. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PDvMMv78PuEw4T5atLeJeS
Every dropped tick wrote its own warning, so a handler wedged against a 1s period produced 3,600 identical lines an hour, indefinitely — the log fills up with the same sentence and buries whatever else the process had to say. The log is now collapsed to the first skip of a contiguous run, with a single summary carrying the count once the run ends (or when the trigger is stopped mid-run). Net volume is one warning for an isolated skip, unchanged from before, and two for a run of any length. The `skip` EVENT still fires once per dropped tick: it is a programmatic signal a consumer counts, and collapsing it would be a semantic change. The streak is per-run, like every other counter here, so a restart resets it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PDvMMv78PuEw4T5atLeJeS
The package was left at 0.3.37 while every other workspace is 0.3.38. Dependents use `workspace:*`, so no lockfile change is needed. The "Driving a workflow" example imported the package for its side effect only, then used `IntervalTrigger` and `PollingTrigger` — copying it produced two ReferenceErrors. Named imports patch `Workflow.prototype` just the same. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PDvMMv78PuEw4T5atLeJeS
Coverage Report
File CoverageNo changed files found. |
sroussey
merged commit Aug 10, 2026
77c65a5
into
claude/libs-issues-triage-prs-mh6x2o-237
10 checks passed
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.
Stacks onto #679 (
claude/libs-issues-triage-prs-mh6x2o-237), which introduces@workglow/triggers. This targets that feature branch, notmain, so it can be merged into #679 before it lands.Three MEDIUM defects found reviewing #679, plus two LOW chores. Because the package has not shipped, the API/semantics changes here land alongside its first published version — no consumer can observe the intermediate state, and no deprecation is ever needed.
The defects
1.
stopemitted for a generation superseded by a restartBaseTrigger.releaseWhenIdleguarded the_runclear with an identity check but emittedstopunconditionally on the next line.Failure scenario. A caller does
trigger.stop()without awaiting it (or aborts a caller signal) and thentrigger.start(...)— the ordinary "reconfigure and restart" shape. The old generation's handler is still in flight, so the drain completes later; when it does, it emitsstopeven though the new generation owns the trigger and is actively firing. An observer latched onstopconcludes the trigger is dead and stops watching a live trigger;trigger.runningreadstrueinside its ownstoplistener.The emit now sits inside the same identity guard, after the clear, so a real stop still reports
running === false. A superseded generation stays silent and the newer one reports its own stop. Every path reachingreleaseWhenIdlewas traced: the only behaviour change is the restart-mid-drain case.2.
firesOnChangeloses a change when the handler throwsPollingTrigger.runTickassignedstate.previous = resultbefore awaiting the handler. A handler rejection propagates up torunTickChain, which reports it — leaving the advanced baseline in place.Failure scenario. A change feed polls a source, sees
a -> b, and the handler's sink is down for one call.bis never offered again: the next poll observesb, compares against a baseline that already readsb, and stays quiet. The update is lost silently, with nothing distinguishing "handled" from "dropped", and it is unrecoverable —errorBackoffcounts poll failures only and never sees a handler rejection.The baseline is now restored when delivery throws, making delivery at-least-once. Retries are paced by the poll period (no extra ticks, and the overlap policy still gates them) and always carry the freshest result — the restored baseline is the last value the handler accepted, so if the source moves
b -> cwhile the sink is down, the next fire deliversc, not a queue ofbs. The restore is skipped when a later tick already advanced the baseline (reachable underoverlap: "concurrent"), which would otherwise resurrect a value that has since been delivered successfully.3. A hung poll wedges the trigger, and every dropped tick warns
Two halves of the same incident.
Failure scenario.
polldoes afetchagainst a black-holed host. It never settles and never throws, so: the tick stays in flight forever; under the defaultoverlap: "skip"every later tick is dropped;errorBackoffnever engages (a hang is not a throw); and nothing at all reaches theerrorevent. The trigger simply goes quiet. Meanwhile each dropped tick writes its own identical warning — at a 1s period that is 3,600 lines an hour, indefinitely, burying whatever else the process had to say.stop()never resolves either, because it awaits the pending tick.pollTimeoutMs(new option) bounds one poll. Exceeding it aborts the signal handed topolland fails the tick with a newTriggerPollTimeoutError, which counts as a poll failure — reported onerror, and it driveserrorBackofflike any other. The deadline is raced against the poll rather than merely signalled: a poll that ignores its signal never settles, so aborting alone would leave the tick in flight. Releasing the tick is also what un-wedges the skip branch and boundsstop(). The default staysundefined(no deadline), so a legitimately slow poll is not broken on upgrade — pinned by a characterization test.skipevent still fires once per dropped tick — it is a programmatic signal with existing coverage, and collapsing it would be a semantic change.Chores
packages/triggerswas left at0.3.37while every other workspace is0.3.38. Dependents useworkspace:*, so no lockfile change.IntervalTrigger/PollingTrigger— copying it produced twoReferenceErrors. Named imports patchWorkflow.prototypejust the same.Testing
Every regression test was written first and confirmed failing against the unfixed code:
a restart during stop() does not emit stop while the trigger is runningrunningAtStopis[true]a failed handler does not consume the changepayloadsis["a", "b"], missing the redeliveryrejects an invalid pollTimeoutMspollTimeoutMs fails a hung poll and the loop keeps goingthe deadline aborts the signal handed to the polla timed-out poll drives errorBackoffconsecutiveFailuresstays0a contiguous run of skips logs once, then a count when it endsstopping mid-skip still reports the skip countThree further tests are guards that pass before and after, and would fail against a naive version of each fix:
a failed handler does not resurrect a value a later tick already delivered(theconcurrentbaseline guard),a hung poll wedges the trigger when no timeout is configured(pins the opt-in default), andan isolated skip logs once and adds no summary(pins that the ordinary intermittent case does not double its log volume).No existing test body was modified.
bun scripts/test.ts trigger vitest— 5 files, 147 passed (up from 135), run against a realbuild:packagesoutput rather than source stubsbun run test:vitest:unit(full sweep) — 438 files passed / 2 skipped, 5194 passed / 47 skipped, 0 failedbun run build:types,bun run --filter @workglow/triggers lint,bun run format— all clean🤖 Generated with Claude Code
https://claude.ai/code/session_01PDvMMv78PuEw4T5atLeJeS
Generated by Claude Code