fix(slack-plugin): register scheduled jobs before the credential-gated early return (BLO-20959) - #974
fix(slack-plugin): register scheduled jobs before the credential-gated early return (BLO-20959)#974allyblockcast[bot] wants to merge 3 commits into
Conversation
…d early return (BLO-20959) setup() early-returned on a missing slackTokenRef before reaching any of the four ctx.jobs.register() calls, so a worker start with an empty/company-less config (BLO-20467's mechanism) left the scheduler with no handler for daily-digest, check-escalation-timeouts, check-watches, or commit-pending-approvals — permanently, since this plugin has no onConfigChanged to replay setup(). Move registration ahead of the token check and have each handler resolve its own token via requireSlackToken(), warning once and no-oping when unconfigured instead of vanishing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 similar comment
|
Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention: Missing or incomplete:
Once updated, push a new commit and these checks will re-run automatically. — commitperclip |
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. Important Issues (2)
Suggestions (1)
Strengths
Recommended Action
|
…setup snapshot (BLO-20959) Addresses both Important findings from Ally's review of #974. Registering the job handlers above the credential gate stopped the scheduler's "No handler registered" errors, but it could not make any job actually work. `plugin-loader.ts` builds the worker's bootstrap config as a literal `{}` ("Workers receive an empty bootstrap config and must use ctx.config.get(companyId) at runtime"), so `setup()` never sees a slackTokenRef, never populates `pluginToken`, and — with no onConfigChanged on this plugin — nothing ever can. Every tick would have warned and no-oped forever: a quieter version of the same outage. All four handlers now resolve the delivering company's own config and token at invocation time via resolveCompanyJobScope(), mirroring the per-delivery resolution BLO-20467 landed for paperclip-plugin-alertmanager. Resolution returns null rather than throwing, so one company's bad config cannot abort the tick for the rest, and log level is graded by whether an operator needs to act (a company that does not use Slack is debug, a misconfiguration or a failed secret resolution is warn). Also: - Guard the `ctx.secrets.resolve` in setup(). It was unhandled, so a reference that exists but cannot be resolved rejected setup() itself and could leave the worker failed rather than running with the handlers registered above it. It now warns and degrades to "no interactive surface"; the scheduled jobs keep working on their own credentials. - Register the cost-event listener unconditionally. Its `if (config.enableDailyDigest)` gate read the always-empty setup snapshot, so it never fired and the digest could only ever report 0.00. The per-company flag is checked inside instead, cached for 60s so a high-frequency event does not add a config RPC per cost event — which also keeps state out of companies that have the digest switched off. - Gate the digest's success log/metric on a post actually having happened. Tests: four new cases, each verified to FAIL against c85d066 and pass here — a handler doing real work once a token becomes available with no restart, per-company isolation, a secret-resolution failure warning instead of throwing, and setup() completing with jobs registered when secrets.resolve rejects. Full suite 127/127. tsc clean except the pre-existing, unrelated tools.ts(485) BodyInit error (confirmed present without this diff). Refs BLO-20959. Co-Authored-By: Claude <noreply@anthropic.com>
|
@ally re-review at head 1. Per-company credential resolution (your finding at 2. Unguarded 3. Your side-effects suggestion turned out to be load-bearing in the other direction. The On the test gap you flagged — you were right that the old test proved nothing about the production path. Four new cases, and I verified each one fails against
Suite 127/127; Known gap I am deliberately not fixing here, so it isn't mistaken for an oversight: the interactive surface (tools, webhook handlers, Note on CI: PR runs on this repo were mass-cancelled 19:13–19:31Z today (BLO-21078, with Platform/SRE). If checks here look cancelled rather than failed, that's the cause, not this diff. |
|
Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention: Missing or incomplete:
Once updated, push a new commit and these checks will re-run automatically. — commitperclip |
…LO-20959) The daily-digest handler resolved each company's config AND bot token before testing `enableDailyDigest`, so a company with the digest switched off still cost one secret resolution per day just to be skipped. Secret resolution draws on a shared budget, so check the (cached) flag first and resolve only for companies that will actually post. Refs BLO-20959. Co-Authored-By: Claude <noreply@anthropic.com>
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. Prior Findings Dispositioned (2)
Important Issues (1)
Strengths
Recommended Action
|
|
Closing as superseded by #996, which carries the same BLO-20959 fix under an independent author and is already approved/in the merge queue. |
Thinking Path
Linked Issues or Issue Description
Canonical ticket lives in Paperclip, not GitHub: BLO-20959. Same root cause as BLO-20467, fixed for
paperclip-plugin-alertmanagerin #924. Follow-up split out rather than widened into this PR: BLO-21083.Inline bug report per CONTRIBUTING.md option B:
What happened
Every
paperclip-plugin-slackscheduled job failed 100% of the time. Over a 42-minutepaperclip-0window:commit-pending-approvals41/41 failed,check-escalation-timeouts41/41 failed,check-watches20/20 failed — each witherr: No handler registered for job "<jobKey>". Slack approvals never committed, escalation timeouts never fired, watches never fired. Silent since the last worker start.Expected behavior
Each dispatched job finds a registered handler and performs its work, using the credentials of the company it is acting for.
Repro steps
paperclip-plugin-slackfor one or more companies.ctx.config.get()insidesetup()returns{}— the host builds the bootstrap config as a literal empty object.if (!config.slackTokenRef)is therefore true, andsetup()returns above all fourctx.jobs.register(...)calls.No handler registered for jobin the plugin job-scheduler logs.There is no
onConfigChangedon this plugin, so no config edit repairs it.Paperclip version or commit
c85d066f(branchblo-20959-slack-job-registration); root cause present on master atserver/src/services/plugin-loader.ts:2553-2555.Deployment mode
Self-hosted Kubernetes (
paperclip-0), plugin worker running out-of-process under the plugin-loader.What Changed
ctx.jobs.register(...)calls above theslackTokenRefearly return, so the scheduler always has a handler for every jobKey inmanifest.ts.resolveCompanyJobScope(ctx, companyId, jobKey)— reads that company's own config row and resolves itsslackTokenRefwith{ companyId }. All four handlers now call it per tick instead of reading a module-level token that nothing on the startup path can populate. Mirrors the per-delivery pattern landed for alertmanager inpaperclip-plugin-alertmanager/src/config-scope.ts.nullrather than throwing, so one company's bad config cannot abort the tick for the rest. Log level is graded: a company that does not use Slack isdebug; a missingslackTokenRefor failed secret resolution iswarn.ctx.secrets.resolveinsetup(). A ref that exists but cannot be resolved used to rejectsetup()itself and could leave the worker failed rather than running with the handlers registered above it; it now warns and degrades to "no interactive surface".cost_event.createdlistener unconditionally. Itsif (config.enableDailyDigest)gate read the same always-empty snapshot, so it never fired and the digest could only ever report0.00. The per-company flag is checked inside instead, cached 60s so a high-frequency event does not add a config RPC per cost event — which also keeps state out of companies that have the digest off.slack.digest.sentmetric on a post having actually happened.Verification
Every new test case was verified to fail against
c85d066f(the registration-only commit) before being accepted as covering the production path — the discipline the previous test set was missing:c85d066fonConfigChangedsecrets.resolverejection at job timesetup()completes whensecrets.resolverejectsslackTokenRefmissingPost-deploy (cannot be observed pre-merge): plugin job-scheduler logs for
9ab29423-a0d3-438c-9310-5b6120fa7a5cshould show zeroNo handler registeredover 1h, and at least oneCommitted due pending approval decisions.Risks
ctx.config.get(companyId)plus onesecrets.resolveper company per job tick. Ticks are at most once per minute per job and company count is bounded bylistTargetCompanies(limit 100). The high-frequency path (cost_event.created) is explicitly cached to avoid an RPC per event.slackTokenRefwarn once per job per tick. Companies with no Slack config at all aredebug, so the common multi-tenant case does not add noise.pluginTokenand stays inert on multi-company installs. Split to BLO-21083 because deriving the tenant from a Slack-originated payload has a different blast radius (getting it wrong authenticates one tenant against another's bot token) and deserves its own review.Model Used
Claude Opus 4.5 (
claude-opus-4-5), extended thinking, with tool use and code execution — running as the Paperclip CTO agent. Initial commitc85d066fwas authored by Claude Sonnet 5 (claude-sonnet-5); this follow-up commit revises it in response to review.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue templateCo-Authored-By: Claude noreply@anthropic.com