Repository navigation
Add a Slack-shaped delivery adapter with retry and dedupe (#355) - #683
Conversation
|
Verdict: INC at 0ed55e2 Reviewed at this tip only. All 9 checks are Blocker: a retryable Slack failure is classified as permanent, so the reminder is dropped after one call
Probe (fake transport answering a fixed error, no network): The tests only cover the permanent direction with Fix
Non-blocking
Risk: T3 ( |
|
Verdict: PASS at f10d427 Ran:
Not run:
Reviewed at this tip only. All 9 checks are The INC at Red without the source change: reverting Non-blocking, unchanged from before
Risk: T3 ( |
A reminder has to reach the owner with the desktop app closed, so its delivery cannot be a client toast. The adapter keeps Slack's chat.postMessage shape with the transport injected, and owns the policy around it: one key per reminder so the same reminder sent again is not a second message, exponential backoff that honours Retry-After, three attempts at most, and no retry for a structural failure. The fake transport and fake timers keep the tests offline. Slack's own transient codes — internal_error, fatal_error, service_unavailable and request_timeout — pass on their own, so they join network and rate_limited on the retry budget: one of them no longer ends the delivery and is not remembered as a refusal. Everything else stays structural. Rebased onto current main; the only conflict was the core-host.md section that #645 also touched, resolved by keeping both.
f10d427 to
93b3e5a
Compare
|
Verdict: PASS at 93b3e5a The tip moved (rebase onto The adapter is sound on its own terms: one key per reminder with dedupe, an in-flight join, at most three attempts, exponential backoff floored by Ran: node --import ./test/harness-resolve.mjs --test --test-timeout=90000 --test-concurrency=6 "test/*.test.js" (in
Ran: node --import ./test/harness-resolve.mjs --test test/notify.test.js
Ran: GET /repos/ThinkFlowLab/FlyBot/compare/cbce2b03...93b3e5a
Ran: GET /repos/ThinkFlowLab/FlyBot/commits/93b3e5a3/check-runs
Ran (red without the source change), each on a copy of this tip with the tests kept:
Not run:
Not blocking, one line each:
|
Written-by: Dev4
Fixes #355 (first half) — a transport-agnostic delivery adapter with Slack's
chat.postMessageshape, a fake Slack transport for tests, and the delivery policy around it: one key per reminder, exponential backoff that honoursRetry-After, three attempts at most, no retry for a structural failure, zero transport calls while the channel is off. Real Slack/email transport, the 24/7 runtime and external approval links stay out, per the spec of record (issue comment6313181873).This tip answers the INC on
0ed55e26: Slack's own transient codes (internal_error,fatal_error,service_unavailable,request_timeout) now join the retry budget instead of being filed as permanent, so one flaky minute no longer costs the owner the reminder. Each of the four has its own case.This tip also rebases the branch onto current
main(cbce2b03): the branch had gonedirtyagainst main, so it is a fresh commit on the new base rather than the old one. Code is byte-identical tof10d427b; the only conflict wasdocs/subsystems/core-host.md, which #645 also touched, resolved by keeping both —#645'sSilent creationbullet and the changedkickoffsentence are intact, and my row plus my### Out-of-app delivery, first half (#355)section were re-inserted at their anchors. The PASS atf10d427bis therefore superseded by this tip and needs one re-judgement.Reproduce
Nothing in the repo could deliver a reminder outside the app, and nothing could be tested offline:
postMessage, no typed failure a caller can act on (rate_limitedwith itsRetry-After,channel_not_found,network), and no dedupe: handing the same reminder to a sender twice would send it twice.ERR_MODULE_NOT_FOUNDforsrc/host/notify.js, all 12 cases fail. Each rule was then proven by disabling it in the module and watching its own case go red (below), because a new module has no earlier version to revert to.What changed
core/src/host/notify.js— the adapter and its policy.createNotifier({ transport, channel, enabled?, attempts?, baseDelayMs?, maxDelayMs?, log?, delay? })speaks Slack's shapepostMessage({ channel, text, blocks?, threadTs? })→{ ok: true, ts }or a typed{ ok: false, error }; the transport is injected and optional fields are passed only when given.send({ key, text, blocks?, threadTs? })→{ state: 'sent', ts, attempts }/'duplicate'/'skipped'/{ state: 'failed', error, attempts, permanent }. One key per reminder: a key that already went out is answered with the firsttsand never reaches the transport; a key handed over while in flight joins that attempt.min(base · 2^(n-1), max)(500 ms, 1 s by default) andretryAfterMsfrom a 429 is a floor, never a ceiling. Three attempts at most; running out is not a verdict about the channel, so a later call may try again.network,rate_limited, and Slack's owninternal_error,fatal_error,service_unavailable,request_timeout— all inRETRYABLE, so each is retried on the same budget and backs off the same way. Structural answers (channel_not_found,not_in_channel, an argument Slack will keep refusing, a shape we do not know) still end the delivery on the first call.channel_not_found,not_in_channel, anything untyped) is permanent: one call, and a secondsend()for that key is answered without another call. A thrown error andnetworkare the same broken link.enabledis checked before every attempt, so switching a channel off stops a reminder that is already waiting out a backoff; a channel that is off makes zero transport calls. Sent keys are capped at 1 000 process-local entries and never persisted.fakeSlack(script)is the test stand-in: records every call, scripts each answer (a function or anErrortoo), andrateLimited(ms)/channelNotFound()/networkFailure()spell the three failures.core/test/notify.test.js— 12 cases: the Slack shape and optional fields, dedupe after success, joining an in-flight send, a 429 that waits whatRetry-Afterasks, growing backoff, a thrown error retried, three attempts then a later run may retry, a missing channel not retried, a channel that is off, switching off during a backoff, and the shape validation. Every retry is driven by fake timers; no network, no key.docs/subsystems/core-host.md— a Out-of-app delivery, first half (Proactive reminders 3/3: 24/7 runtime and Slack/email delivery #355) section and theKey filesrow.patches/ds-bot/files/— regenerated (generate, thencheck), becausecore/gained two files.schedules.jsis not wired — second half, once a real channel exists.What I ran
Tip SHA:
93b3e5a327dc758ccdbd9cb11728169e4c4f14fb(Node 22.23.3,TZ=UTC, basecbce2b03), run in a fresh checkout of that base with this PR's five files applied:core/test/notify.test.js→ 12/12 pass, one case per transient code included.core, full suite: 1232/1232 pass, 0 fail (node --import ./test/harness-resolve.mjs --test --test-concurrency=4 "test/*.test.js")..github/scriptssuite: 173/173 pass (node --import ./core/test/harness-resolve.mjs --test --test-concurrency=4 ".github/scripts/*.test.mjs"),ds-bot-patchesincluded withgitavailable.node core/scripts/check-arch-docs.mjs→arch-docs: OK (5 changed file(s))against the real base (merge base =cbce2b03).node .github/scripts/changelog.mjs check→ 13 fragments well formed.main, so the two files atf10d427bthat Team cost controls: per-team activity budget (C) and quiet hours / mute (E) — A and D are shipped #645's docs edit collided with were re-merged rather than reverted.npm run verifyaborts on this box insidenpm --prefix core test: the default test concurrency exhausts process spawns (spawnSync /bin/sh EAGAINincheck-arch-docs,esbuild … EAGAINinclient-bundle), which is why the suite is run here with--test-concurrency=4. Every step ofverifywas therefore run on its own: those above, plus plugin lint (0 errors, all three plugins pack and import),plugins/social319/319,plugins/tree89/89,plugins/timeline105/105,.github/scripts153/153,lint:interop0 errors +test:interop37/37,lint:live-e2e0 errors +test:live-e2e16/16.Red without the source change
Against main the file does not exist, so all 12 cases fail at import. At this tip, disabling one rule at a time in
src/host/notify.js:sent.has(key)check →#355: the same reminder handed over twice is one messagefails:{ state: 'sent', ts: 'ts-2', attempts: 1 }instead of{ state: 'duplicate', ts: 'ts-1', attempts: 0 }— the same reminder sent twice.Math.max(backoff, retryAfterMs)→#355: a rate limit waits what Retry-After asks, then sends oncefails: the second call leaves before its 2 000 ms floor.permanent: trueon a structural failure →#355: a missing channel is a permanent failure and is not retriedfails, and the rest of the file reportscancelledByParentbecause the mutated adapter is left waiting on a mocked timer that the test does not advance.permanent: trueforinternal_error(the INC) →#355: Slack's own transient codes are retried, not remembered as permanentfails with{ state: 'failed', error: 'internal_error', attempts: 1, permanent: true }instead of{ state: 'sent', ts: 'ts-2', attempts: 2 }: one internal error was the end of that reminder.RETRYABLEback tonew Set(['network', 'rate_limited'])turns exactly one case red —#355: Slack's own transient codes are retried, not remembered as permanent— and restoring it gives 12/12 again.Not run
slack.com.dev/bench.shneedspnpm, which this box does not have) and no load test of the 1 000-entry cap, which is asserted by construction, not measured.Risk
risk-gate.mjs: every path is T2 at most (core/src/host/notify.jsis not in the T3 file list, the docs are T0 and the patch mirror T1), but the change is past T2's 250-line limit (654 added lines, 5 files), so the gate escalates it. It needs a PASS at this tip plus greentests-ok, and the gating agent merges it after that.