Slack channel
Slack is a pure transport: it turns Slack events into IncomingMessages, renders replies/status back, and adds nothing else. All Slack-specific behavior — triggers, receipts, formatting, attachments — lives here.
- Code:
src/channels/slack.ts,src/channels/slack/(the adapter's concerns that stand apart:attachments.ts— which files reach the model and their budgeted downloads;statusCard.ts— the card's Block Kit frame and the live-card record;dedupe.ts— the handled-set and the redelivery guard;lookups.ts— the cached display-name, email and team-URL reads and the permalink),src/core/dispatch/messages.ts(turnContent: the attachment assembly of a model turn),src/channels/slackTriggers.ts(pure trigger gating),src/channels/slackCatchUp.ts(reconnect catch-up + orphaned-card sweep),src/channels/slackCatchUpStatus.ts(catch-up status record + bot-scope check),src/channels/mrkdwn.ts,src/channels/health.ts(/healthzbody),src/channels/processMetrics.ts(/healthz.process),src/core/drain.ts(drain deadline + catch-up window minimum),deploy/cloudflare/preflight.mjs(bot deploy preflight),src/deploy/restart.ts+deploy/cloudflare/worker.ts(deploy restart:/admin/restartauthorization + refusal decision; the Container DOstop()) - Docs: How a request flows, Worker topology, AGENTS.md invariant 1
- Tests:
src/channels/mrkdwn.test.ts,src/core/dispatch/messages.test.ts(attachment assembly),src/channels/slack.test.ts,src/channels/slack/attachments.test.ts,src/channels/slack/statusCard.test.ts,src/channels/slack/dedupe.test.ts,src/channels/slack/lookups.test.ts,src/channels/slackCatchUp.test.ts,src/channels/health.test.ts,src/channels/processMetrics.test.ts,src/core/drain.test.ts,deploy/cloudflare/preflight.test.mjs,src/deploy/restart.test.ts,src/deploy/restartRun.test.ts,src/deploy/liveGate.test.ts
Behavior
Triggers: (a) channel mention (
@switchboard …); (b) DM; (c) thread follow-up in a thread the bot participates in — no re-mention needed. Top-level channel posts still require a mention; bot messages and non-file_sharesubtypes are ignored. Participation is re-derived from the thread itself (threadIfBotInIt), never from in-memory state — and the thread page that check reads is handed toSlackIO.history()(SlackEvent.thread), so a follow-up costs oneconversations.replies, not two identical ones; the mention path, which has no such check, fetches inhistory()as before.Acceptance receipt: the moment a message is accepted for handling, the bot reacts 👀 (
eyes) on it — before any model/tool work. Fire-and-forget: missingreactions:writescope or a duplicate reaction logs and never blocks handling. The adapter starts the request's root the momenthandle()runs — before the redelivery guard — and everything it does beforedispatch()(the guard, this ack, the catch-up note, the downloads, the display names) is oneslack.receivespan withdedupe(fresh/duplicate),caughtUpandfilesattrs; a dropped redelivery is a root with that one child and no run (tracing.md item 18). The message carriesreceivedAt(our clock at entry) andoriginAt(the messagets), so the card can sayqueued 6m 00s before we saw itafter a restart.Status card: one bot message per run, edited in place — spinner + agent + model + elapsed seconds headline; body is the agent's own ✓/✱/○ checklist (via
update_status). A ticking card means the run is alive; error paths mark ❌. Slack's native shimmer status is re-upped every 75s during long runs. The closed card's total is the run's own — the card shell is frozen at the registry's finish stamp (CardShell.freeze, tracing.md), so a close painted after a slow reply reads the same seconds as the run page and the index — and a run whose loop threw closes its card after the finish, from the same stamp. The card's clock is the request's receipt, and through setup a 5 s heartbeat repaints it with the step in flight —◐ *coding* onm· 42s — attaching the workspace…— until the run loop's own heartbeat takes over; every close carries the request's elapsed time (📦 … · not started (repo access) · 12s,❌ setup failed · … · 184s), and when the card's gate passes the shape line (32s getting ready · 2m 30s thinking · …) and the queued caption lead the closed card's detail above the checklist (tracing.md item 18).Formatting: agents write standard Markdown; the adapter converts to mrkdwn (bold/strike/headers/links/bullets) and never rewrites code (fenced or inline). Asterisk emphasis renders bold whichever dialect the model wrote —
**x**collapses to*x*and a single*x*is already mrkdwn bold — because mapping*x*to italic (standard-Markdown semantics) made the same verdict line arrive bold or italic depending on the model's dialect of the moment;_x_is the one italic form. This also matches the ingress reading (humanizeMessageTexttreats Slack*x*as bold). Replies over 3500 chars are chunked at line boundaries.Attachments: files on the triggering message and thread history are downloaded (needs
files:read) and passed to the model, budgeted and spent newest-first over thread history. Within one message the downloads run concurrently (candidates are chosen first — type, declared size, count budget — then fetched together, then the byte budgets are applied in the original order, so the file that gets cut on overflow is the same one the serial loop cut) and the image and document passes overlap; messages stay sequential because each one's spend decides the budget left for the next. Two families, each with its own budget:- Images (
image/jpeg|png|gif|webp): ≤10/message, ≤5MB each; thread-wide ≤20 images/24MB. Sent as provider-native image blocks. - Documents — PDFs and text/code/CSV/log files (
text/*plus a small allowlist of text-ish mimetypes; generic types resolved by filename extension): ≤10/message, ≤10MB each; thread-wide ≤20 docs/32MB. A PDF rides as base64 to a provider-native document block (Anthropic;@anthropic-ai/sdk0.39 supports it in the stable Messages API); OpenAI-compatible endpoints get an inline-text placeholder naming the file (their binary-PDF support is inconsistent). A text/code file is decoded to UTF-8 and inlined as a fenced text part labeled with its name — provider-agnostic, no SDK dependency. - Secret-file denylist (defense against credential exfiltration to the model, including third-party OpenAI-compatible providers): a file whose name looks like credentials, a private key, or secret config is never inlined — it is skipped-with-note and its bytes are never downloaded or decoded into the prompt. The denylist overrides text classification (checked before the mimetype/extension allowlist), because the whole risk is a secret file whose type/extension otherwise reads as harmless text. Matched on the filename, case-insensitive, independent of
path.extname(which returns""for dotfiles like.env):.envin any position (config.env,.env.local, …),id_rsa*,credentials.json,*service-account*.json,*-key.json, and the extensions.pem.key.p12.pfx.npmrc.netrc.ini.cfg.conf. Relatedly,application/jsonand.json/.jsonlare not inlinable-by-default (JSON is a common secret container), so a JSON file is never "fair game" just by its mimetype or extension. - Skipped attachments (unsupported type, secret-file denylist, oversize, over-budget, failed download) are named to the model so it never claims a file "didn't come through". A file is only reported skipped when it is neither a usable image nor a usable document.
- Images (
Display-name resolution: the adapter resolves the channel's name (
conversations.info, needschannels:read/groups:read) and the sender's display name (users.info, needsusers:read;profile.display_name→real_name→ handle) and passes them to the core asIncomingMessage.channelName/userName(for the live-view run label), plussourceUrl— the message's Slack permalink (slackPermalink:<team url>/archives/<C>/p<ts>with the thread qualifier for a reply), built from theauth.testURL resolved once per process — so the run page's Request block can link back to the thread (live-view.md item 13). Best-effort and cached (module-levelMaps, one API call per new id, bounded to 1000 entries FIFO): a lookup failure leaves the field undefined — the core falls back to the raw id — and never delays or fails a dispatch. The core stays channel-agnostic; these are optional hints.- 6a. Channel visibility for authorization (authorization.md item 7):
SlackChannelDirectory(src/channels/slackChannelDirectory.ts) implements the core'sChannelDirectoryseam over the sameconversations.infocall —is_im/is_mpim→dm,is_private→private, elsepublic— and the bot wires it as the dispatcher's channel facts (src/index.ts), so every run is stamped with its channel's visibility and a public channel's runs are readable by everyone while a private channel's or DM's stay grants-only. Unlike the name lookup this one is a fact the policy relies on, so it fails closed: any error, missing scope, or empty reply isunknown(grants-only, never public), remembered for the TTL (ten minutes, bounded to 1000 channels) and logged once per channel per window under[authz]; aslack:D…id isdmwithout an API call; non-Slack ids are the static mapping's. Scopes:channels:readfor public channels andgroups:readfor private ones — both already inREQUIRED_BOT_SCOPES, so the startup scope check (item 7) names a missing one; the bot receives no group-DM events (message.mpimis not subscribed), sompim:readis not needed and a missing scope only ever degrades tounknown, never a crash. The dispatcher waits at mostCHANNEL_DIRECTORY_TIMEOUT_MS(1.5 s) for the answer and otherwise stampsunknown— one coldconversations.infoper channel per TTL is the whole cost on the message path.
- 6a. Channel visibility for authorization (authorization.md item 7):
Reconnect catch-up: Socket Mode does not queue or replay events while the app is disconnected, so every bot rollover (deploy → graceful drain closes the socket → cold start) was a silent blackout — mentions posted in it got no 👀, no run, and the caller waited forever. On every websocket
connected(first start and each reconnect) the adapter scans every channel the bot is a member of (users.conversations, needschannels:read/groups:read) and re-dispatches what it never saw, through the samehandle()as a live event (👀, status card, run). Slack is the durable "was this handled" record (invariant 6 — no persisted last-seentsan ephemeral container would forget): a message counts as handled when the bot has posted in its thread after it (a status card or answer), or it carries the bot's own 👀 reaction and is younger thanACK_GRACE_MS(30 s — the card is on its way). A 👀 alone on an older message is a run that died between the ack and its card (a rollover can kill the process seconds after it acked a thread reply; treating 👀 as terminal would make the reply vanish) and is re-run (the ack can be lost to a missingreactions:write; a status card cannot). A bounded same-process seen-set (channel:ts, 5000 FIFO) additionally stops a message that arrived live and appears in the scan from running twice. Selection mirrors the live triggers exactly via the sharedslackTriggers.ts: a top-level message needs a mention; a thread reply needs a mention or a bot-participating thread; bot messages and non-file_sharesubtypes never count. Window: messages inside the last 30 min (slack.catchUp.windowMinutes, defaultDEFAULT_WINDOW_MS) — older unanswered mentions are left alone, because re-running a stale request is worse than the human re-posting it. The window is bound to the drain: the graceful drain closes the socket the moment SIGTERM arrives and holds the container until in-flight runs finish (DRAIN_DEADLINE_MS= 15 min,src/core/drain.ts), and Cloudflare boots the replacement only after exit — so a deploy over a run in flight blacks Slack out for the run's remaining duration, and the catch-up on the next connect is the ONLY recovery for mentions posted in that gap. The default window is therefore derived from the deadline:DEFAULT_WINDOW_MS ≥ MIN_CATCH_UP_WINDOW_MS = DRAIN_DEADLINE_MS + COLD_START_ALLOWANCE_MS(15 + 5 = 20 min), asserted at module load and by a unit test. A configuredwindowMinutesbelow that minimum (or non-positive) is kept as given — never clamped — and warned about at startup naming the drain deadline (catchUpWindowWarning). Keeping the socket open during the drain was rejected: a mention accepted at minute 14 would be killed at the 15-min cap with a frozen card, whereas the catch-up re-runs it intact. A replayed message says so in its thread (a request that sat minutes through a drain with no 👀 is indistinguishable to the caller from being ignored): the catch-up handshandle()the event withcaughtUp: true, and before the run starts the thread gets⏱ Picked up <N min | under a minute> after it was posted: the bot was restarting … Handling it now — no need to re-send.(catchUpDelayNote, pure; a negative/clock-skewed delay renders as "under a minute"). Best-effort like the 👀 ack — a failed note is logged, never fails the run. Thread parents are scanned 7 days back (≤5 pages of 200) so a follow-up in a days-old PR thread is found; replies are fetched only for threads whoselatest_replyis inside the window, and paged in full (≤5 pages of 200) because Slack returns replies oldest-first — a single page of a long thread would drop exactly the newest, in-window messages. Scope and bounds (each fails toward a message staying un-run, with one bounded exception toward a re-run, noted below — never toward a silent drop): DMs are not caught up (onlypublic_channel/private_channel; catch-up is about channel mentions andim:*scopes are off in the recommended rollout); a channel with >1000 top-level messages in 7 days, or a thread with >1000 replies, is truncated at the page cap — in such a truncated thread an acked-and-answered message older than the grace whose bot reply sits on a dropped page is re-run (its 👀 is not terminal; >1000 replies inside a 30-min window is the accepted trade-off); a broadcast reply whose parent is older than the 7-day lookback is never seen; and a caught-up run that dies before posting anything is re-dispatched on each reconnect inside the window regardless of the 👀 (with or withoutreactions:write— an ack past the grace is by design not a receipt), bounded by the window. Missed messages are dispatched oldest-first, each hand-off fire-and-forget like a live event; a channel whose scan fails is logged and skipped, never blocking the others or the connect. The scan is two phases: READ — channels are scanned with bounded concurrency (CATCH_UP_CHANNEL_CONCURRENCY= 4 channels at once; within a channelCATCH_UP_THREAD_CONCURRENCY= 4 replies fetches at once;src/core/mapLimit.ts), so a busy workspace's scan costs the longest scan, not the sum of every round trip — this runs on every reconnect while a caller may be waiting for a receipt; then ACT — in the original channel order, so re-dispatch order is exactly the serial loop's. Default on;slack.catchUp.enabled: falseremoves the hook. Observable without container logs: the container's stdout is not in Workers Logs, so a scan that cannot run (the token lackingchannels:read/groups:readmakesusers.conversationsfailmissing_scopeand the whole catch-up a silent no-op) must show somewhere else. Each scan records its outcome in an in-process, live-only record (slackCatchUpStatus.ts— a restart starts empty and the next connect refills it; not durable state, so invariant 6 holds):lastRunAt,channels,missed,skippedChannels(per-channel scan failures), anderrorwhen the channel listing — or theauth.testbefore it — failed; a clean scan clearserror. On the firstconnectedthe adapter also compares the token's granted scopes (response_metadata.scopeson theauth.testresult) with the adapter's required set (REQUIRED_BOT_SCOPES:app_mentions:read,chat:write,channels:history,groups:history,files:read,reactions:write,channels:read,groups:read,users:read), logs any missing ones aterrorlevel and keeps them asmissingScopes(absent when none, or when the grant is unknown — advisory, never a false alarm).GET /healthzcarries the record ascatchUpwith undefined fields omitted, and the bot deploy preflight (item 8) prints a WARNING — never a refusal, the deploy may be the fix — namingcatchUp.errorand the missing scopes. The pass is oneslack.catch_uproot on the span log (tracing.md item 20), its counts as attrs.Rollover-safe status cards: a
wrangler deployindeploy/cloudflare/rolls the bot container. Cloudflare's rollout sends SIGTERM and allows up to 15 min before SIGKILL, and the bot's graceful drain (src/index.ts) uses that window to finish in-flight runs — so one deploy on top of a run is survivable. A second deploy while the first is still draining replaces the draining instance at once: the run dies, its card freezes mid-spin, and the run vanishes from/runs(the registry is in-memory) — an invisible state where the reader cannot tell "slow" from "dead". Three layers, each independent: (a) Deploy preflight —npm run deployrunspreflight.mjsfirst. Since the run ledger's handoff (run-history item 39) it no longer refuses for runs in flight:GET /healthzreportinginFlight > 0ordraining: trueis a WARNING naming the count and the handoff (a resumable run continues on the next generation under its own card; a ship pipeline still in flight is what the drain warning names), and the deploy proceeds — nobody waits on a run. It still refuses whilewrangler containers list --jsonshows theswitchboard-switchboardserverapplication in a non-settled state (provisioning/updating/unknown — a rollout still in progress; a second rollout landing on one in progress kills the draining run), and fails closed: unreachable bot, a bare-okbody (a Worker predating the preflight), an impossibleinFlight, a wrangler failure, or an app missing from the listing all refuse;SWITCHBOARD_DEPLOY_FORCE=1is the explicit, warned bypass.deploy restart's decision (decideRestart) follows the same rule: runs in flight and a drain under way warn, only the fail-closed cases refuse. The live gate judges a container by its identity (build.commit, orstartedAtafter a restart) whether or not it is draining: one that already serves the deployed identity IS live, whatever is rolling it next; when the identity is the old one, the reason names the drain. A same-commit rollout (a Worker recovered with--only bot, a forcedallwith no code change) drains an old container that serves the deployed commit too, so a draining same-commit container counts only with astartedAtlater than the onedeploy allread before the upload; without that reading it waits. (b) Drain is visible — on SIGTERM the process sets a shutdown notice and every live card's heartbeat frame carries· ⏸ deploy in progress — this run continues through the bot restart; the closed card never does. Since the handoff (run-history item 39) the notice tells the truth for both kinds of run: a resumable run is handed to the next generation and continues there under the same card, a ship pipeline finishes here before the exit./healthzis JSON ({ok, inFlight, draining, drainDeadlineMs, build, slack, process}, plusdrainStartedAtwhile draining andgeneration— the run ledger generation this process writes under, run-history.md item 35 — when the ledger is on) so the preflight can read the same facts;processis{rssMb, heapUsedMb, eventLoopLagP99Ms}fromsrc/channels/processMetrics.ts— the bot is one Node process and under many concurrent runs it is the first thing to fail, so the load harness (load-harness.md item 12) reads memory and event-loop lag here, the lag being the p99 over the window since the previous poll (the Worker's keep-alive and the deploywakecheck only the HTTP status).slackis the Socket Mode state (src/channels/slackSocketStatus.ts, in-process and live-only like thecatchUprecord):{connected}plus, once known,since(ISO of the last transition) andconnects(lifetime count for this process — 1 is the boot handshake, more means reconnects). The HTTP server starts BEFORE the Slack handshake, so on a cold start/healthzlegitimately answersslack: {connected:false}until the socket lands — which is exactly how the listen-before-connect ordering is observable from outside (no container stdout needed), and how a silently dead socket (HTTP fine, bot deaf) shows off-box.buildis{ commit, builtAt? }— the commit the image was built from, stamped bydeploy/cloudflare/write-build.mjsinto a gitignoredbuild.jsonthatnpm run deploywrites and the Dockerfile COPYs (optional glob: an image built by hand reportscommit: "unknown"; a dirty tree gets a-dirtysuffix). It exists because deployed ≠ live: afterwrangler deploythe old container keeps answering/healthzwhile it drains, sonpx tsx src/cli.ts deploy alltreats the bot step as done only when a non-draining container reports the deployed commit (src/deploy/liveGate.tsdecideLive: not JSON / draining / no build identity /unknown/ other commit /-dirty→ waiting with the reason; past the drain deadline + cold start → timeout, exit non-zero; live only on a commit match), and its preflight wait prints a heartbeat per poll (still waiting — N run(s) in flight (draining: yes/no), waited Xm of 30m) so a long wait is never silent. (c) Orphan sweep — the reconnect catch-up (item 7) also scans, in threads active within the last 2 h, for the bot's own cards still wearing a live glyph (the spinner or the 👀 setup card —LIVE_CARD_PREFIXES, kept next to the glyphs in the dispatcher) whose run is live nowhere this process knows of — not driven here, and not leased on the run ledger by another generation (run-history.md item 36,isLiveCard) — and closes each withchat.updateas❌ interrupted · <label> · <elapsed>plus a detail saying the bot restarted mid-run and to re-send. Slack is the durable record here too (invariant 6): the card is the only trace of a run whose process died. Cards this process is driving (ownsLiveCard) are never touched, so a websocket reconnect without a restart cannot close a running run's card; a card older than 2 h is left alone rather than mislabeled; a failing close is logged and the sweep continues. The/runsindex does not resurrect the dead run — the card is the user-facing state. (d) Restart without a build (deploy restart): a rotated bot secret needs the container restarted, not rebuilt —wrangler secret putupdates the Worker env but a running container keeps the env it started with, and Cloudflare rolls a container only on an image/config change. The Worker'sPOST /admin/restart(bearer: aSWITCHBOARD_INGRESS_TOKENSidentity whosehttp:<subject>actor holdsdeploy:writein the bot's config — the Worker authenticates the bearer against the map it holds, then the Container DO asks the botPOST /admin/restart/authorizefor the grant — naming the authenticated subject in thex-switchboard-restart-subjectheader, which the Worker strips from every request it proxies so only it can speak it, and which the bot decides on from its grants alone, never re-authenticating the bearer against its own token map (a rotation puts the new map in the Worker's env at once and in the container's only after this restart, so a bearer check in the container would refuse the rotation itself); without the header the route falls back to the bearer, the whole check/admin/crashruns — and stops only on a 200; a container that was not running is started to answer and, if the bearer is allowed, that start already put the current env live) has the Container DO check/healthzwith the preflight's rules (409 whileinFlight > 0ordraining, unless{force:true}), thenstop()— SIGTERM, the same graceful drain as a rollout — and the next request (the keep-alive cron within a minute, or the CLI's 15 s poll) starts it again with envVars computed AT START from the DO's current env (deploy/cloudflare/worker.tscontainerEnv). Because the image is unchanged,build.commitcannot identify the new instance:/healthzcarriesstartedAt(process start, ISO) and the CLI (npm run cli -- deploy restart, CLI only likedeploy all) is done only once a non-draining container reports a LATERstartedAt(decideRestarted, sharingdecideLive's waiting/timeout core); a 409 is waited out with the same heartbeat asdeploy all. The drain's count starts atdispatch()entry, not after executor attach: an acked message is in flight before anything slow (a SIGTERM one second after the 👀 would otherwise see 0 in flight and exit).Redelivered events are deduped — one message, one run: Slack re-delivers an event whose original delivery was never acked. The shape: a mention posted seconds into a deploy drain is answered by the reconnect catch-up (⏱ note, card, answer) and then re-delivered by Slack minutes later through the live socket path — a bare
[event]+[run]for the samechannel:tswith no[catch-up]line and no ⏱ note, while/healthz'scatchUp.lastRunAtstill reads the earlier scan. Unguarded, that path runs everything unconditionally —handle()marking the same-process seen-set is not enough when only the catch-up scan ever reads it — and the duplicate run repeats every side effect (a second review verdict, a second auto-approve). The guard (dedupeDelivery, first thing inhandle()): (a) same-process — the (channel, ts) pair is already in the seen-set (handled live or by this process's catch-up) → dropped,[redelivery] … dropped: already handled in this process, no API call; the pair is claimed before the guard's only await, so concurrent deliveries of the same pair serialize onto check (a) however the awaits interleave, and catch-up'sonMissedre-checks the set just before dispatch (its scan-timealreadyHandledread may predate a live claim). (b) cross-process (the first handling belonged to a container that is gone): a live delivery older thanSTALE_DELIVERY_MS(60 s — live deliveries arrive in seconds; only redeliveries and blackout flushes are older) pays ONEconversations.repliesfetch and is dropped when the bot has already posted in the thread after the message (botRepliedAfter, the same predicate the catch-up trusts — invariant 6: Slack is the durable record). A stale event with 👀 but no bot reply after it still runs (item 7's ack-then-killed shape — re-running it is the point), fresh events pay nothing, and an unfetchable thread fails OPEN (a lost request is worse than the duplicate this guard prevents; the worst case is exactly the pre-guard behavior). Catch-up replays (caughtUp: true) skip both checks — the scan already judged the message unanswered against the same Slack state — but still claim the pair.Long command output is a Markdown file in the thread (command-registry.md item 27).
SlackIO.attach({ name, text, lead })uploadstextwithfiles.uploadV2into the thread (channel_id,thread_ts,filename/title=name,initial_comment= the lead in mrkdwn): Slack renders a.mdupload as formatted Markdown (bold, lists, inline code, links) in a collapsed preview with an expand control — one message where a 12 000-charmcp showused to be four chunks. The dialect translation is the dispatcher's (toMarkdownDocument), not the adapter's: the adapter uploads the document it is handed. Needsfiles:write(now inREQUIRED_BOT_SCOPES, so/healthznames it when the app lacks it); an upload that fails for any reason falls back to the chunked text reply carrying lead + text, so the output always arrives.
Validation criteria
| Criterion | Proof |
|---|---|
Redelivery guard (item 9): same-process redelivery dropped with no API call (timestamps taken from a real redelivery); cross-process stale redelivery dropped after one replies fetch when the bot replied after it; stale-but-unanswered (👀-then-killed) runs; bot messages before the event don't count; fresh events never fetch and a repeat of the same ts is dropped; replies failure → runs (fail-open); no botUserId or malformed ts → no fetch, runs; caughtUp skips both checks but claims the pair | [unit] src/channels/slack/dedupe.test.ts::dedupeDelivery (redelivery guard) (7; red-verified: check (a) disabled → the two same-process cases fail) |
Redelivery guard — wiring: handle() consults the guard before the 👀 ack and before any work; catch-up's onMissed re-checks the seen-set at dispatch time | [agent] code-review of handle() entry + onMissed (item 7's Bolt-level harness drives the connected hook, not a live socket delivery into handle(), so this row stays code-review). [agent] live: next deploy-window mention that gets both a catch-up replay and a Slack redelivery shows one run and a [redelivery] … dropped container log line. |
Markdown→mrkdwn conversions; code fences and inline code untouched; *x* and **x** both render bold (deterministic emphasis), _x_ the one italic form | [unit] src/channels/mrkdwn.test.ts (incl. ::normalizes both emphasis dialects to bold; red-verified) |
| Mention triggers a run; reply lands in-thread | [agent] Mention the bot in a channel with a trivial request; expect a status card then a reply in the same thread. |
| Thread follow-up works without re-mention | [agent] After a completed run, reply in-thread without mentioning the bot; expect a new run. |
| 👀 reaction lands on acceptance, before the status card | [agent] Mention the bot; the 👀 reaction must appear on your message before/with the status card. Without reactions:write, expect a [ack] missing_scope log line and otherwise unchanged behavior. |
| Trigger gating (no bot-loop, no un-mentioned top-level posts, no mention double-handling) | [unit] src/channels/slack.test.ts::classifyMessage… + ::threadIncludesBot… — the gating decision is a pure exported function the Bolt handler calls. |
| Image passthrough within budgets (types, size, count, thread byte budget, login-page detection, failed downloads) | [unit] src/channels/slack/attachments.test.ts::fetchImages… (stubbed fetch fixtures). Live spot-check remains: attach a PNG and ask "what does this show?". |
| Document passthrough within budgets (PDF→base64, text/code→UTF-8, extension fallback for generic types, images/unsupported skipped, size/count/thread-byte budgets, login-page vs real .html, failed downloads) | [unit] src/channels/slack/attachments.test.ts::fetchDocuments (PDF + text/code ingestion within budgets) (stubbed fetch fixtures). Live spot-check remains: attach a PDF and ask "summarize this". |
Secret-file denylist: credential/key/config files (.env/config.env/.env.local, credentials.json, *service-account*.json, *-key.json, id_rsa*, .pem/.key/.p12/.pfx/.npmrc/.netrc/.ini/.cfg/.conf) are skipped-with-note and never decoded, overriding a text/JSON mimetype; plain .json no longer inlines by mimetype; safe .txt/.csv/code/PDF still ingest | [unit] src/channels/slack/attachments.test.ts::classifyDocument (secret-file denylist …) + ::fetchDocuments (secret files skipped-with-note, never decoded) (stubbed fetch fixtures; the never-decoded guarantee is proven by fetch never being called for a denied file). |
Attachment assembly: images then documents then user text; PDF→document part, text→fenced text part | [unit] src/core/dispatch/messages.test.ts::turnContent (attachment assembly) |
Provider mapping: Anthropic document→native base64 PDF block (title = filename); OpenAI-compat document→inline-text placeholder (never raw base64) | [unit] src/providers/anthropic.test.ts, src/providers/openaiCompat.test.ts |
| Mention stripping | [unit] src/channels/slack.test.ts::stripMention |
Slack app footer stripping: trailing *Sent using* <@APP|Name> / Sent using <@APP> lines (added when an app posts on a user's behalf, e.g. the Claude Slack plugin) are removed before dispatch so inline commands like repo onboard owner/name parse — exactly those two shapes (asymmetric bold is user text), anchored to the END of the text rather than to its own line — the raw event text actually arrives as friction report *Sent using* <@APP> on one line, so a line-anchored regex would let *Sent reach the command parser (Unknown option \*Sent`; repo list` masks it by ignoring trailing text) — optionally followed by a bracketed sender attribution, repeated for stacked footers, and a mention+footer-only message strips to empty; the phrase mid-text is the user's own words and is untouched | [unit] src/channels/slack.test.ts::stripMention — Slack app 'Sent using' footer (incl. ::drops a same-line trailing footer (the shape Slack actually delivers), ::drops the footer when a bracketed sender attribution follows the mention; red-verified) |
| Display-name resolution: channel + user names resolved and cached (one API call per new id), display_name→real_name→name preference, API error → undefined without throwing, failure not cached | [unit] src/channels/slack/lookups.test.ts::resolveChannelName / resolveUserName (best-effort, cached)::* |
Channel visibility (item 6a): conversations.info → public / private / dm (is_im, is_mpim), slack:D… and non-Slack ids answered without an API call, one lookup per channel per TTL (injectable clock) with a bounded FIFO cache, concurrent first lookups share one call, any failure → unknown remembered for the TTL and logged once per window, isMember → unknown | [unit] src/channels/slackChannelDirectory.test.ts::SlackChannelDirectory.info — visibility from conversations.info::*, ::SlackChannelDirectory.isMember — the membership seam (not enumerated yet)::*; the stamp and the bounded wait end to end: src/core/dispatcher.test.ts::run history write path …::Slack directory stamp…, ::a slow channel directory cannot hold a reply… |
Reconnect catch-up — selection: un-acked top-level mention in-window is picked (threaded to itself); 👀-acked skipped only while younger than ACK_GRACE_MS, or once the bot replied after it — a 👀-acked message older than the grace with no bot reply after it is picked, top-level and in-thread; bot-replied-after skipped (a human reply is not a receipt); older-than-window / no-mention / bot / subtyped skipped; file_share kept with files; same-process seen-set skipped; in-thread mention picked even under a days-old parent; un-mentioned follow-up picked only in a bot-participating thread; reply the bot answered after skipped; oldest-first ordering | [unit] src/channels/slackCatchUp.test.ts::findMissed… + ::isAckedByBot (red-verified: ack, bot-reply-after, and window checks each mutated → 5/2/2 failures) |
Reconnect catch-up — window vs drain: DEFAULT_WINDOW_MS (30 min) ≥ DRAIN_DEADLINE_MS (15 min) + COLD_START_ALLOWANCE_MS (5 min); catchUpWindowWarning is silent for unset / ≥ 20 min, names the configured value, the 15 min drain deadline and the 20 min minimum below it, and flags a non-positive or non-finite value — never clamps | [unit] src/core/drain.test.ts::catch-up window vs drain deadline + ::catchUpWindowWarning… (red-verified: DEFAULT_WINDOW_MS lowered to 15 min → module-load guard throws and the test file fails) |
Reconnect catch-up — runner: scans every member channel (users.conversations public+private, unarchived); fetches replies only for threads active in the window; nothing dispatched when all acked (the common reconnect); a channel's API failure is logged and skipped without throwing; a rejecting dispatch does not stop the rest; history asked from the parent-lookback oldest and paged by cursor; a long thread's replies paged by cursor so an in-window follow-up on page 2 is found | [unit] src/channels/slackCatchUp.test.ts::catchUpMissedMentions (runner over the Slack Web API) |
Reconnect catch-up — wiring: the hook is registered on the receiver's connected event when enabled (default) and absent with slack.catchUp.enabled: false; emitting connected runs the scan and re-dispatches a missed mention through handle() | [unit] src/channels/slackConnectedHook.test.ts::connected-hook wiring… — Bolt-level harness: a real SocketModeReceiver whose socket client emits connected (exactly what a reconnect fires; the client only dials out on start()), with a fake Web API on app.client — the scan runs (auth.test, users.conversations, conversations.history; outcome + socket-state records stamped), a missed mention reaches dispatch() with the 👀 ack and ⏱ note, a second connect skips it via the seen-set, and enabled: false leaves zero listeners (red-verified: hook registration renamed → both enabled-path tests fail; registration made unconditional → the disabled-path test fails) |
Reconnect catch-up — late-pickup note: a replayed message's thread gets ⏱ Picked up N min after it was posted … no need to re-send; whole minutes; sub-minute or negative delay → "under a minute" | [unit] src/channels/slack.test.ts::catchUpDelayNote::*. [agent] The next deploy-time catch-up replay (see the live row below) shows the ⏱ note above the run card, with N matching the gap between the message and the new container's connect. |
| Reconnect catch-up — live: a mention posted during a bot deploy gets 👀 + a run once the new container connects, and is not run twice | [agent] Post <@bot> agent:general say hi in a channel the bot is in ~30 s after npm run deploy in deploy/cloudflare/ (before the new container's [catch-up] scanned … log line); expect a [catch-up] C…: 1 missed message(s) re-dispatched log, then 👀 + status card + reply on the message; a second deploy must log 0 missed. A rollover where the old container drains while the new one connects has no blackout to catch, so pick a quiet moment and post the probe inside the gap; the container's stdout is not in Workers Logs — read the [catch-up] lines from the container directly, or curl -s https://<bot hostname>/healthz and read catchUp.missed / catchUp.lastRunAt. |
Reconnect catch-up — status record: the runner records every scan (lastRunAt ISO, channels, missed, skippedChannels); a failed channel listing is recorded as error (and returns an empty scan); per-channel failures count as skippedChannels with no error; a clean scan clears a previous error; missingScopes survives across outcomes and an empty list is absent, not [] | [unit] src/channels/slackCatchUp.test.ts::catchUpMissedMentions — outcome record + src/channels/slackCatchUpStatus.test.ts::catch-up status record… |
Reconnect catch-up — scope check: missingBotScopes returns the required scopes absent from the grant, in required order; extras ignored; array or comma-separated input; an unknown grant reports nothing; the required set is exactly the adapter's nine | [unit] src/channels/slackCatchUpStatus.test.ts::missingBotScopes… |
Reconnect catch-up — /healthz carries catchUp with undefined fields omitted (error/missingScopes present only when set; {} before the first scan) | [unit] src/channels/health.test.ts::healthPayload — catchUp |
Reconnect catch-up — deploy preflight warns (never refuses) on catchUp.error or a non-empty missingScopes, naming each; a payload without catchUp or an empty list says nothing; warnings ride along on an allowed AND a refused decision | [unit] deploy/cloudflare/preflight.test.mjs::bot deploy preflight — catchUpWarnings() |
Reconnect catch-up — live: the scope check fires on first connect and the record reaches /healthz | [agent] After a deploy, curl -s https://<bot hostname>/healthz | jq .catchUp → lastRunAt within the last few minutes, channels ≥ 1, no error, no missingScopes. Negative path: on a Slack app whose bot token lacks channels:read, expect error containing missing_scope and missingScopes: ["channels:read"], and node deploy/cloudflare/preflight.mjs printing WARNING (not blocking …) with both lines. |
Socket state — the record starts {connected:false} at boot, a connect stamps since + lifetime connects, a disconnect flips it and moves since, a reconnect bumps the count, and reads are copies | [unit] src/channels/slackSocketStatus.test.ts::slackSocketStatus::* |
Socket state — /healthz carries slack with absent fields dropped ({connected:false} alone at boot); no key when the state is not given | [unit] src/channels/health.test.ts::healthPayload — slack socket state::* |
Socket state — the listen-before-connect ordering is provable from ONE /healthz poll on ONE clock: the payload's httpListeningAt (stamped in the listen() callback) is strictly earlier than slack.since of the boot connect (connects: 1). Racing a poller against the boot window instead is NOT observable through the Worker shim — startAndWaitForPorts releases held requests on a coarse port poll (~seconds), which exceeds the ~1.5–2 s window (requests sent before the process existed are answered a fraction of a second after the connect) | [agent] After any cold start: curl -s https://<bot hostname>/healthz | jq '{httpListeningAt, slack}' → httpListeningAt < slack.since with connects: 1. |
Rollover-safe cards — deploy preflight decision: idle + settled → allow; runs in flight → allow with a WARNING naming the count and the handoff (never a refusal); draining: true → allow with a WARNING naming the ship-pipeline risk; container app provisioning/updating/unknown → refuse naming the state (ready and active are settled); fail closed on unreachable bot, bare-ok/non-JSON body (names the old-Worker bypass), impossible inFlight, wrangler failure, app not listed; force → allow with a warning naming the problems; problems and warnings reported at once | [unit] deploy/cloudflare/preflight.test.mjs (CI job bot-worker) |
Rollover-safe cards — deploy restart decision: runs in flight and a drain under way are warnings, allowed without --force; no JSON body or an impossible count refuses (force bypasses) | [unit] src/deploy/restart.test.ts::decideRestart::* |
Rollover-safe cards — live gate: a draining container serving the deployed commit (or a later startedAt) is live; an old identity while draining names the drain | [unit] src/deploy/liveGate.test.ts::decideLive::a draining container that serves the deployed commit is live only when its startedAt is later than the pre-upload reading — a same-commit rollout drains an old container that serves the commit too (run-history item 39; review F2), src/deploy/liveGate.test.ts::decideRestarted::a draining container with a LATER startedAt is restarted — the restart landed and a further stop is draining it (run-history item 39) |
Rollover-safe cards — deploy preflight live, fail-closed: against a Worker that predates item 8 (bare ok body) node preflight.mjs refuses with the old-Worker hint and exits 1 | [agent] Run SWITCHBOARD_BASE_URL=https://<bot hostname> node preflight.mjs in deploy/cloudflare/ (the origin comes from the profile; deploy all sets it) before the first item-8 deploy; expect preflight REFUSED … bot answered /healthz without JSON ("ok") — the running Worker predates the preflight and exit 1. |
Rollover-safe cards — deploy preflight live, allow: with the item-8 Worker live and idle, /healthz is {"ok":true,"inFlight":0,"draining":false} and node preflight.mjs prints preflight ok and exits 0 | [agent] curl https://<bot hostname>/healthz then SWITCHBOARD_BASE_URL=https://<bot hostname> node preflight.mjs in deploy/cloudflare/ at a quiet moment. |
Rollover-safe cards — drain notice: once setShutdownNotice is set mid-run, the next heartbeat frame's title ends with · ⏸ deploy in progress …; the closed ✅ card never carries it; with no notice, frames are unchanged | [unit] src/core/dispatcher.test.ts::shutdown notice on the live status card |
Rollover-safe cards — a dispatch is in flight from its first line: activeRunCount() is 1 before io.history(), the setup card or any executor attach, and 0 once dispatch() returns — on every path, including the config-command fast path and refusals — so a SIGTERM landing between the channel's 👀 ack and the first card finds a run to wait for instead of draining at once (the producer of the acked-then-vanished runs the catch-up grace re-runs) | [unit] src/core/dispatcher.test.ts::a dispatch is counted in flight from entry — before history, the setup card or any attach …, ::an early-return path (config command) releases the count: 0 after dispatch |
Rollover-safe cards — /healthz body: {ok:true, inFlight, draining, drainDeadlineMs} always, plus drainStartedAt (ISO) only while draining, so an operator can see how long the blackout can still last | [unit] src/channels/health.test.ts::healthPayload; the wiring (inFlight() = runs + pending reflections, draining flipped by the drain) is covered by the live preflight rows |
Rollover-safe cards — /healthz carries process ({rssMb, heapUsedMb, eventLoopLagP99Ms}) whenever the entrypoint samples it, and no key otherwise; the sampler reads whole MiB and a fresh lag window per read | [unit] src/channels/health.test.ts::healthPayload — process metrics::*, src/channels/processMetrics.test.ts::startProcessMetrics::* |
Rollover-safe cards — build identity: readBuildInfo parses build.json (commit, optional builtAt); missing/malformed/blank → {commit:"unknown"} without throwing; healthPayload carries build when given, omits builtAt when absent, no key when not given | [unit] src/channels/health.test.ts::build identity on /healthz::* |
Rollover-safe cards — live gate decision: live only when not draining AND build.commit matches the deployed commit (prefix ≥7, never -dirty); draining → waiting naming the in-flight count and drainStartedAt; other commit → waiting naming both; non-JSON / no build / unknown → waiting with the reason; the same reason is a timeout at the deadline (drain deadline + cold start); heartbeat line names in-flight, draining, waited-of-budget | [unit] src/deploy/liveGate.test.ts::* |
Rollover-safe cards — the plan gates the bot on its drain (liveGate kind: "health" + healthUrl on the bot step, none on memory/resident — the sandbox has its own kind: "sandbox" gate, release-and-deploy.md item 16) and --dry-run says so | [unit] src/deploy/plan.test.ts::WORKER_SPECS / workersFor / DEPLOY_ORDER::the bot and the sandbox carry live gates… |
Rollover-safe cards — live: npx tsx src/cli.ts deploy all over a bot with a run in flight prints a heartbeat line per retry, then after the upload prints bot: deployed, not live yet — old container still draining … until bot: live (commit <HEAD7>, drained after Ns); curl /healthz on the new container shows build.commit == git rev-parse HEAD; the result table's live column reads live; exit 0 | [agent] Run a real deploy all from a clean origin/main checkout at a moment with one run in flight; capture the heartbeat + not live yet + live lines and the final table; compare /healthz build.commit to HEAD. Negative path: with a stale container that never drains within 20 min (drain deadline + cold-start allowance) the table reads deployed, not live: … and the exit code is 4. |
| Rollover-safe cards — orphan selection: a bot card starting with any live glyph (◐◓◑◒👀) inside the window is picked; ✅/❌/⏹ cards, human messages with a glyph, bot replies without one, and cards older than the window are skipped; a card this process owns is skipped | [unit] src/channels/slackCatchUp.test.ts::findOrphanedCards… |
The closed card's total is the run's own: freeze(finishedAt) ends every later frame's elapsed at the finish stamp, so a done close painted 9 s later still reads the run's duration | [unit] src/core/statusCardFrame.test.ts::createCardShell — every paint comes from one builder::freeze(finishedAt) ends every later frame's elapsed… |
Rollover-safe cards — interrupted frame: label + elapsed kept; spinner, the — thinking (…) / — running <tool> (…) suffix, and the trailing drain notice all dropped (the notice in both its unicode-⏸ and Slack :shortcode: history forms — an interrupted card must not claim the bot is "finishing this run"; a label merely mentioning a deploy mid-text is untouched); mrkdwn entities un-escaped; detail explains and says to re-send | [unit] src/channels/slackCatchUp.test.ts::interruptedCardFrame |
A follow-up reuses the thread page the bot-in-thread check fetched: history() makes no second conversations.replies; the mention path still fetches | [unit] src/channels/slack.test.ts::SlackIO.history — thread reuse and concurrent attachment downloads::uses the thread the handler already fetched…, ::fetches the thread itself when no prefetched page is given… |
| A message's images download concurrently (every fetch in flight before any finishes) and come back in the original order with budgets intact | [unit] src/channels/slack.test.ts::SlackIO.history — thread reuse and concurrent attachment downloads::downloads a message's images concurrently…; budgets: src/channels/slack/attachments.test.ts::fetchImages (attachment ingestion within budgets)::* |
| Catch-up scans channels concurrently (bounded) and still re-dispatches in channel order | [unit] src/channels/slackCatchUp.test.ts::catchUpMissedMentions…::scans channels concurrently (bounded)…; src/core/mapLimit.test.ts::* |
Rollover-safe cards — sweep runner: threads active inside the wider 2 h window are fetched (one replies call per thread, shared with the mention scan) and a frozen card in a thread quiet for 41 min is closed while its 👀-acked mention is NOT re-run; without ownedHere+onOrphanedCard the sweep is off (no wider fetch); a rejecting close is logged, the rest still closed, and only successful closes are counted (N of M … closed) | [unit] src/channels/slackCatchUp.test.ts::catchUpMissedMentions — orphaned-card sweep |
| Rollover-safe cards — live: a card a killed process left spinning is closed as interrupted by the next container's connect | [agent] Requires a deploy over a run: at a quiet moment start <@bot> agent:general count slowly to 200, one number per tool call and, while it runs, SWITCHBOARD_DEPLOY_FORCE=1 npm run deploy TWICE ~60 s apart (the incident shape); expect the card to first show ⏸ deploy in progress …, then freeze, then within ~2 min of the new container's [catch-up] scanned … log line be rewritten to ❌ interrupted · … with the re-send detail, and [catch-up] C…: 1 orphaned status card(s) closed in the log. A card older than 2 h stays as it is — the sweep deliberately does not relabel history. |
Restart without a build — pure halves: decideRestart refuses on inFlight > 0 / draining / a non-JSON body unless forced (red-verified: relaxing the in-flight check fails the test); authenticateRestart (the Worker's half) 503 without a token map, 401 unknown bearer, the identity otherwise, never echoes a token; authorizeRestartSubject (the bot's half for the subject the Worker names) decides 403/200 on http:<subject>'s grants alone; authorizeRestart (/admin/crash, and /admin/restart/authorize without the header) is authenticate + that; stripRestartSubject removes the header and nothing else from a proxied request; parseRestartAuthorization relays the bot's 200/401/403/503 and turns anything else into a 503 (a bot without the route never restarts); decideRestarted keeps waiting on the OLD startedAt (red-verified: accepting an equal startedAt fails the test), on draining, on a body without startedAt, times out at the live-gate deadline, live only on a LATER startedAt; /healthz carries startedAt (ISO) | [unit] src/deploy/restart.test.ts::*, src/channels/adminRestartAuthorize.test.ts::*, src/deploy/liveGate.test.ts::decideRestarted, src/channels/health.test.ts::startedAt on /healthz |
Restart without a build — runner + command: deploy restart POSTs /admin/restart with the $SWITCHBOARD_DEPLOY_TOKEN bearer and {force}, waits out a 409 with a [deploy:restart] heartbeat every --poll up to --wait-max (then fails naming --force, nothing stopped), fails at once on 401/403, and exits 0 only once a later startedAt answers; CLI only, --only accepts bot alone | [unit] src/deploy/restartRun.test.ts::*, src/core/commands/deploy.test.ts::deploy.restart |
Restart without a build — live: with the bot idle, wrangler secret put a rotated value, then SWITCHBOARD_DEPLOY_TOKEN=… npm run cli -- deploy restart; expect SIGTERM sent (old container started …), [drain] SIGTERM in npm run tail, then restarted — startedAt <later> (was <old>) within ~1 min, build.commit unchanged, and the bot using the new value (e.g. a rotated MEMORY_TOKEN no longer 401s). Also: with a run in flight the command prints the heartbeat and does not stop the container; an unauthorized bearer gets 403 | [agent] Human-gated (production bot; needs the bearer). |
Item 10: SlackIO.attach uploads the text as a snippet in the thread (files.uploadV2: channel, thread_ts, filename, content, the lead as initial_comment in mrkdwn); a failed upload falls back to the chunked text reply carrying lead + text; files:write is in the required scope set | [unit] src/channels/slack.test.ts::SlackIO.attach…::*, src/channels/slackCatchUpStatus.test.ts |
| Items 2–3: the card ticks from the ack and names a slow attach; every close carries the elapsed time; the shape and queued lines lead the detail | [unit] src/core/dispatcher.test.ts::executor provisioning by agent resources::a slow attach shows on the card…, src/core/statusCardFrame.test.ts::createCardShell — every paint comes from one builder::a close before the run started paints…, ::a close's shape and queued lines lead its detail… |