afterhours-shift-manager/docs/sticky-schedule-post.md

157 lines
7.5 KiB
Markdown
Raw Permalink Normal View History

# Evaluation: sticky / reused weekly schedule post
_Spike for issue #135 — decide how the Monday two-week schedule post should
behave across weeks. This document records the decision and the rationale; the
recommended option (b) is implemented in the same PR._
## Background
`src/weekly-post/app.py` (the Monday 7am ET Lambda) originally, every week:
1. `chat_delete` the previous week's schedule post (stored `ts` from
`get_schedule_post`),
2. `chat_postMessage` a brand-new two-week schedule message, and
3. `save_schedule_post(channel_id, new_ts, …)`.
So every Monday the post got a new `ts` (new permalink), re-notified the
channel, and lost any thread/reactions. During the week it steadily sank as
people chatted.
The infrastructure to edit in place **already exists**: in-week shift changes
(`pick` / `drop` / `swap` / `admin`) call `_refresh_schedule_post()`
(`src/slack-bot/app.py`), which does a `chat_update` against the stored `ts`.
## Options considered
### (a) Reuse the same post across weeks — pin for reachability
On Monday, `chat_update` the existing message to roll the two-week window
forward instead of delete + repost, and add a native Slack pin so the post is
reachable from the channel header.
- **Pros:** stable permalink; no weekly re-notification; keeps any
thread/reactions; trivial change (reuses the `_refresh_schedule_post`
`chat_update` pattern already in the codebase).
- **Cons:** the message stays where it was first posted — it does **not** rise
to the bottom as the channel gets new activity. A native pin only surfaces the
post in the channel's pinned-items panel (the header); it does **not** hold the
message at the bottom of the timeline, which is the actual requirement from
#135.
### (b) True stickybot behavior — keep it at the bottom — **recommended**
Slack has no native "sticky" message. Stickybot-style bots subscribe to channel
message events and **delete + repost** the bot message whenever someone else
posts, so it is always last.
- **Pros:** the schedule post is always at the literal bottom of the channel
timeline — exactly the behavior #135 asks for.
- **Cons:** every bump is a new `ts` (the permalink changes); the post
re-surfaces / re-notifies on activity; requires a new `message.channels` event
subscription, the `channels:history` scope (a one-time reinstall), and
debounce machinery to avoid thrashing on bursts.
### (c) Status quo — delete + repost weekly
- **Pros:** the weekly repost bumps the post to the bottom once a week.
- **Cons:** new permalink every week; a weekly re-notification; loses
thread/reactions; the post still sinks for the rest of the week.
## Decision
Adopt **(b) true bottom-of-channel stickiness**. Adam wants literal
bottom-of-channel placement of the schedule post, and explicitly accepts the
tradeoffs that come with it:
1. **The permalink changes on each bump** — every delete + repost produces a new
`ts`, so any saved link to the post goes stale.
2. **The post re-surfaces / re-notifies on activity** — re-posting pings the
channel (subject to the debounce window below), rather than sitting quietly.
3. **New machinery** — a `message.channels` event subscription, the
`channels:history` scope (one-time reinstall), and a debounce to avoid
thrashing on bursts.
The **native pin (option a's complement) was rejected**: it only gives header
reachability — the post shows up in the pinned-items panel — but it does **not**
hold the message at the bottom of the timeline, which is the requirement. A pin
is therefore not a substitute for bottom-stickiness.
## What changes (option b)
### Weekly rollover — `src/weekly-post/app.py`
- **Delete + repost on Monday** — the previous week's stored post is
`chat_delete`d and a fresh schedule is `chat_postMessage`d so the message
lands at the bottom of the channel every Monday regardless of in-week
activity. The delete failure is non-fatal; the handler always reposts.
- **First-run:** when there is no stored post to delete, the handler
`chat_postMessage`s a fresh message and stores its `ts`.
- **Repost rollback:** if the new `ts` can't be persisted after the repost has
landed, the fresh message is `chat_delete`d before the error propagates, so an
async retry (which would read the stale, already-deleted `ts`) can't orphan a
duplicate schedule at the bottom of the channel.
- The `pins:write` scope and the `_pin_schedule_post` helper are removed — the
pin is fully replaced by bottom-stickiness.
### Activity bump — `src/slack-bot/app.py`
The bump lives in the **slack-bot** Lambda, which already owns the Slack events
request URL. On a `message.channels` event in the schedule channel
(`handle_channel_message`):
- **Debounced** so a burst of chatter triggers at most one bump:
`SCHEDULE_BUMP_DEBOUNCE_SECONDS = 180` (3 minutes). The last bump time is
stored as `last_bump_ts` on the schedule-post record, so the debounce check
reads no channel history.
- **Idempotent / safe** — the bump is skipped when:
- the delivery is a Slack **retry** (`X-Slack-Retry-Num` set) — under
`process_before_response=True` the delete+repost runs before the 200 ack, so
a slow run can be retried; we never bump on a retry (the next real message
bumps anyway),
- the triggering message is a **thread reply** (`thread_ts`) — a threaded reply
doesn't push the schedule down the main timeline,
- there is no stored post,
- the triggering message is the bot's own (`bot_id`) or a non-user message
subtype (edits, deletes, joins, …),
- the debounce window has not elapsed, or
- the bot isn't in the channel (`not_in_channel`).
- Otherwise it **stamps `last_bump_ts` optimistically before** the delete/repost
(so a retry fired mid-bump — or after the run dies before the final save — is
debounced and can't orphan a duplicate), `chat_delete`s the stored post,
`chat_postMessage`s the current `build_week_schedule` blocks, then
`save_schedule_post`s the new `ts`. If the old message is already gone, it
still reposts so the channel always ends with the schedule.
### Interaction with `_refresh_schedule_post` and the rollover
All three paths converge on the **single stored `ts`** and never fight:
- **Monday rollover** — `chat_delete`s the old stored `ts`, `chat_postMessage`s
a fresh post at the bottom, and saves the new `ts` (clearing `last_bump_ts` so
the next activity is free to bump).
- **In-week shift edits** — `_refresh_schedule_post` `chat_update`s the current
stored `ts`; it does not change the `ts` or touch `last_bump_ts`.
- **Activity bump** — deletes the stored `ts`, reposts the same content at the
bottom, and saves the new `ts`. Subsequent rollovers and edits then operate on
that new `ts`.
Both the rollover and the bump mint a new `ts` on repost and re-save it
immediately, so `_refresh_schedule_post` always reads the current `ts` from the
record.
## Scope / manifest impact
- `channels:history` (bot scope) and `message.channels` (bot event) are added to
`slack-app-manifest.yaml` so the slack-bot Lambda receives channel messages and
can run the bump. This is a new scope + event, so the app must be
**reinstalled** once for them to take effect.
- `pins:write` is **removed** — the pin is no longer used.
## Follow-ups (not in this PR)
- After deploy, reinstall the Slack app so `channels:history` /
`message.channels` take effect, then confirm a new channel message bumps the
schedule post to the bottom (and that bursts are debounced).
- If the re-notification on each bump proves noisy, revisit the debounce window
or consider posting without a broadcast.