switchroom 0.19.30 → 0.19.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/cli/switchroom.js +1121 -584
  2. package/dist/host-control/main.js +151 -73
  3. package/package.json +1 -1
  4. package/profiles/_base/cron-session.sh.hbs +5 -1
  5. package/profiles/_base/start.sh.hbs +15 -1
  6. package/telegram-plugin/dist/gateway/gateway.js +1435 -551
  7. package/telegram-plugin/edit-flood-fuse.ts +70 -20
  8. package/telegram-plugin/gateway/boot-beacon.ts +364 -0
  9. package/telegram-plugin/gateway/boot-sweep-gate.ts +20 -15
  10. package/telegram-plugin/gateway/gateway.ts +87 -88
  11. package/telegram-plugin/gateway/inbound-spool.ts +39 -0
  12. package/telegram-plugin/gateway/narrative-lane.ts +12 -0
  13. package/telegram-plugin/gateway/obligation-store.ts +28 -0
  14. package/telegram-plugin/gateway/stale-pin-sweep-store.ts +221 -0
  15. package/telegram-plugin/gateway/stale-pin-sweep-wiring.ts +211 -0
  16. package/telegram-plugin/gateway/stale-pin-sweep.test.ts +804 -0
  17. package/telegram-plugin/gateway/stale-pin-sweep.ts +1146 -0
  18. package/telegram-plugin/gateway/status-pin-retarget.ts +15 -2
  19. package/telegram-plugin/gateway/status-pin-store.ts +33 -11
  20. package/telegram-plugin/registry/turns-schema.ts +21 -1
  21. package/telegram-plugin/retry-api-call.ts +46 -21
  22. package/telegram-plugin/shared/bot-runtime.ts +61 -17
  23. package/telegram-plugin/shared/gw-trace-gate.ts +18 -2
  24. package/telegram-plugin/tests/activity-card-wiring.test.ts +7 -7
  25. package/telegram-plugin/tests/activity-drain-fuse-drop-not-failure.test.ts +324 -0
  26. package/telegram-plugin/tests/agent-card-result-footer.test.ts +193 -0
  27. package/telegram-plugin/tests/boot-beacon.test.ts +462 -0
  28. package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +6 -6
  29. package/telegram-plugin/tests/boot-sweep-gate.test.ts +42 -31
  30. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +2 -0
  31. package/telegram-plugin/tests/inbound-spool-progress.test.ts +2 -0
  32. package/telegram-plugin/tests/inbound-spool.test.ts +134 -5
  33. package/telegram-plugin/tests/narrative-lane-golden.test.ts +28 -0
  34. package/telegram-plugin/tests/obligation-determinism.test.ts +2 -0
  35. package/telegram-plugin/tests/obligation-store.test.ts +67 -1
  36. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
  37. package/telegram-plugin/tests/status-pin-store.test.ts +26 -5
  38. package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +161 -0
  39. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +30 -0
  40. package/telegram-plugin/tool-activity-summary.ts +104 -38
  41. package/telegram-plugin/worker-activity-feed.ts +33 -16
  42. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +0 -251
  43. package/telegram-plugin/gateway/dm-pin-sweep.ts +0 -178
@@ -0,0 +1,221 @@
1
+ /**
2
+ * stale-pin-sweep-store.ts — the durable OBLIGATION ledger for the stale-pin
3
+ * sweep (`stale-pin-sweep.ts`).
4
+ *
5
+ * Why an obligation and not a "did we sweep this boot?" flag
6
+ * ---------------------------------------------------------
7
+ * Draining a deep pin stack is not one API call. In a DM each pop costs a
8
+ * re-pin plus an unpin, both rate-gated, so a chat with 21 orphans (klanker,
9
+ * observed 2026-07-29) takes north of a minute of wall clock. The old
10
+ * once-per-boot in-memory `Set` (`dm-pin-sweep.ts`) forgot everything the
11
+ * instant the process died: a gateway restarted mid-drain simply started over
12
+ * — or, once the per-boot pop budget was in play, never finished at all.
13
+ *
14
+ * So the sweep is modelled as a persisted OBLIGATION per `(chatId, threadId)`
15
+ * target: seeded from the pin stores at boot, advanced as pops land, and
16
+ * discharged (`done: true`) only once the drain is VERIFIED empty. A restart
17
+ * mid-sweep reloads the ledger and RESUMES from `popped` rather than forgetting.
18
+ *
19
+ * Durability: the write goes through `atomicWriteFileSync` (src/util/atomic.ts)
20
+ * — tempfile in the same directory, `fsync` on the fd, then `rename(2)`. A
21
+ * power cut leaves either the previous complete ledger or the new one, and the
22
+ * bytes of whichever survives are on the platter. A plain
23
+ * `writeFileSync`+`rename` would give atomicity without durability: the rename
24
+ * can be ordered before the data reaches disk, which is exactly how a crashed
25
+ * host comes back with a zero-length ledger and a chat full of orphan pins that
26
+ * nothing remembers to clean.
27
+ *
28
+ * Shape choice mirrors `status-pin-store.ts`: a whole-file SNAPSHOT, because
29
+ * the obligation set is tiny (one row per chat/topic the agent has ever pinned
30
+ * in) and rewriting it is trivially cheap. PURE with respect to the injected fs
31
+ * seam, so the ledger semantics are unit-testable without touching a disk.
32
+ */
33
+
34
+ /** Chat classes the sweep treats differently. See `classifyChatForSweep`. */
35
+ export type SweepChatKind = 'dm' | 'forum-topic' | 'supergroup'
36
+
37
+ /** One persisted sweep obligation for a single `(chatId, threadId)` target. */
38
+ export interface SweepCursor {
39
+ /** Chat the orphan pins live in. */
40
+ chatId: string
41
+ /**
42
+ * Forum topic the target is scoped to, when known.
43
+ *
44
+ * This is the `(chat, thread)` half of the registry key. Telegram's pin stack
45
+ * is CHAT-WIDE — MTProto `messages.updatePinnedMessage` carries no
46
+ * `top_msg_id` and the Bot API's `pinChatMessage`/`unpinChatMessage` accept no
47
+ * `message_thread_id` — so a "topic pin" is really a chat-level pin whose
48
+ * message happens to live in that thread, and clients render the topic bar by
49
+ * filtering. A bot therefore cannot enumerate pins or target a topic with
50
+ * pin/unpin. The ONE topic-scoped verb it does have is
51
+ * `unpinAllForumTopicMessages(chat_id, message_thread_id)`, and that verb is
52
+ * the whole reason this field must be persisted: without it, a boot after a
53
+ * crash knows a forum chat has orphans but not which topic to drain.
54
+ */
55
+ threadId?: number
56
+ /** Chat class, decided when the obligation was seeded. */
57
+ kind: SweepChatKind
58
+ /** Pops (or drain attempts) committed so far, ACROSS boots. */
59
+ popped: number
60
+ /** True once the drain was verified empty — the obligation is discharged. */
61
+ done: boolean
62
+ /**
63
+ * Boots that have attempted this obligation. Bounded by
64
+ * {@link SWEEP_MAX_ATTEMPTS} so a permanently-undrainable chat (bot kicked,
65
+ * rights revoked, a chat that flood-waits forever) cannot re-burn the pop
66
+ * budget on every boot for the rest of time.
67
+ */
68
+ attempts: number
69
+ /**
70
+ * Message ids this obligation has already issued a targeted unpin for.
71
+ *
72
+ * Load-bearing for the GROUP path, which is not a blind stack drain but a
73
+ * bounded pass over the ids the gateway is on record as having pinned. When a
74
+ * boot spends its per-minute budget half way through that list, this is what
75
+ * lets the next boot resume at the right place instead of re-issuing (and
76
+ * re-charging the flood ledger for) every unpin from the start.
77
+ *
78
+ * Unused by the DM path, which drains by observation and has no id list.
79
+ */
80
+ doneIds?: number[]
81
+ /** Last terminal-ish reason, for operator forensics. Never load-bearing. */
82
+ lastStatus?: string
83
+ /** Wall-clock ms of the last write. */
84
+ updatedAt: number
85
+ }
86
+
87
+ /**
88
+ * How many boots may attempt one obligation before it is forfeited. Generous
89
+ * because a legitimate deep stack is deliberately spread over several boots
90
+ * (the per-minute pin-op budget, and in a DM the 40-pop-per-chat cap): 8 boots
91
+ * covers far more than anything observed on the fleet.
92
+ */
93
+ export const SWEEP_MAX_ATTEMPTS = 8
94
+
95
+ /** Injected fs seam — same shape as the status-pin store's, so the gateway can
96
+ * bind one durable writer for both. `writeFileSync` MUST be durable (fsync +
97
+ * atomic rename); the gateway binds `atomicWriteFileSync`. */
98
+ export interface SweepStoreFsSeam {
99
+ readFileSync: (path: string) => string
100
+ /** Durable atomic replace of `path` with `data` (fsync then rename). */
101
+ writeFileSync: (path: string, data: string) => void
102
+ existsSync: (path: string) => boolean
103
+ }
104
+
105
+ interface SweepEnvelope {
106
+ v: 1
107
+ cursors: SweepCursor[]
108
+ }
109
+
110
+ const SWEEP_ENVELOPE_VERSIONS = new Set([1])
111
+
112
+ /** Stable identity of a sweep target: chat plus topic (topic-less = `-`). */
113
+ export function sweepTargetKey(chatId: string, threadId?: number): string {
114
+ return `${chatId}:${threadId ?? '-'}`
115
+ }
116
+
117
+ function isCursorRow(x: unknown): x is SweepCursor {
118
+ if (x == null || typeof x !== 'object') return false
119
+ const o = x as Record<string, unknown>
120
+ return (
121
+ typeof o.chatId === 'string' &&
122
+ o.chatId.length > 0 &&
123
+ (o.threadId === undefined || typeof o.threadId === 'number') &&
124
+ (o.kind === 'dm' || o.kind === 'forum-topic' || o.kind === 'supergroup') &&
125
+ typeof o.popped === 'number' &&
126
+ typeof o.done === 'boolean' &&
127
+ typeof o.attempts === 'number' &&
128
+ (o.doneIds === undefined ||
129
+ (Array.isArray(o.doneIds) && o.doneIds.every((n) => typeof n === 'number'))) &&
130
+ (o.lastStatus === undefined || typeof o.lastStatus === 'string') &&
131
+ typeof o.updatedAt === 'number'
132
+ )
133
+ }
134
+
135
+ /**
136
+ * Load the obligation ledger. Fails OPEN to `[]` on a missing, unreadable or
137
+ * malformed file: a corrupt ledger must never crash boot. Worst case the sweep
138
+ * re-seeds from the pin stores, which is exactly the pre-ledger behaviour.
139
+ */
140
+ export function loadSweepCursors(path: string, fs: SweepStoreFsSeam): SweepCursor[] {
141
+ if (!fs.existsSync(path)) return []
142
+ let raw = ''
143
+ try {
144
+ raw = fs.readFileSync(path)
145
+ } catch {
146
+ return []
147
+ }
148
+ let parsed: unknown
149
+ try {
150
+ parsed = JSON.parse(raw)
151
+ } catch {
152
+ return []
153
+ }
154
+ if (parsed == null || typeof parsed !== 'object') return []
155
+ const env = parsed as Record<string, unknown>
156
+ if (
157
+ typeof env.v !== 'number' ||
158
+ !SWEEP_ENVELOPE_VERSIONS.has(env.v) ||
159
+ !Array.isArray(env.cursors)
160
+ ) {
161
+ return []
162
+ }
163
+ return env.cursors.filter(isCursorRow)
164
+ }
165
+
166
+ /**
167
+ * Persist the ledger durably. Best-effort relative to fs availability: a write
168
+ * failure is logged, never thrown — a failing ledger degrades the sweep to
169
+ * "forgets across restarts" (the old behaviour), it must not break the gateway.
170
+ */
171
+ export function persistSweepCursors(
172
+ path: string,
173
+ fs: SweepStoreFsSeam,
174
+ cursors: readonly SweepCursor[],
175
+ log: (line: string) => void = (l) => process.stderr.write(l),
176
+ ): void {
177
+ const env: SweepEnvelope = { v: 1, cursors: [...cursors] }
178
+ try {
179
+ fs.writeFileSync(path, JSON.stringify(env))
180
+ } catch (err) {
181
+ log(
182
+ `stale-pin-sweep-store: persist FAILED path=${path}: ${(err as Error).message} — ` +
183
+ `durability degraded; a restart mid-sweep will re-seed instead of resume\n`,
184
+ )
185
+ }
186
+ }
187
+
188
+ /**
189
+ * READ-MODIFY-WRITE one target's row against the file on disk, so a concurrent
190
+ * writer's rows are never clobbered by a stale in-memory snapshot. Mirrors
191
+ * `applyStatusPinRow` in status-pin-store.ts.
192
+ */
193
+ export function upsertSweepCursor(
194
+ path: string,
195
+ fs: SweepStoreFsSeam,
196
+ cursor: SweepCursor,
197
+ log: (line: string) => void = (l) => process.stderr.write(l),
198
+ ): void {
199
+ const key = sweepTargetKey(cursor.chatId, cursor.threadId)
200
+ const others = loadSweepCursors(path, fs).filter(
201
+ (c) => sweepTargetKey(c.chatId, c.threadId) !== key,
202
+ )
203
+ persistSweepCursors(path, fs, [...others, cursor], log)
204
+ }
205
+
206
+ /**
207
+ * The obligations still owed at boot: not discharged and not forfeited.
208
+ *
209
+ * A `done` row is retained in the ledger (rather than deleted) only until the
210
+ * next seed pass; `pruneSweepCursors` drops it. Keeping it for the duration of
211
+ * one boot is what makes a mid-sweep restart idempotent — the resumed sweep
212
+ * sees "already drained" instead of re-draining a chat it just cleared.
213
+ */
214
+ export function pendingSweepCursors(cursors: readonly SweepCursor[]): SweepCursor[] {
215
+ return cursors.filter((c) => !c.done && c.attempts < SWEEP_MAX_ATTEMPTS)
216
+ }
217
+
218
+ /** Drop discharged and forfeited rows — called once the ledger is reseeded. */
219
+ export function pruneSweepCursors(cursors: readonly SweepCursor[]): SweepCursor[] {
220
+ return cursors.filter((c) => !c.done && c.attempts < SWEEP_MAX_ATTEMPTS)
221
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * stale-pin-sweep-wiring.ts — binds the pure stale-pin drain
3
+ * (`stale-pin-sweep.ts`) to the gateway's world, without putting any of that
4
+ * glue inside gateway.ts.
5
+ *
6
+ * The sweeper itself is deliberately dependency-free so its rate gates, its
7
+ * circuit-breaker and its "never trust an `ok:true`" invariant are provable in
8
+ * isolation. Everything it needs from the outside is a function. Assembling
9
+ * those functions is mechanical, but it is ~70 lines of policy (which pins are
10
+ * protected, what counts as pin rights, which env var unlocks the supergroup
11
+ * loop) that belongs next to the drain rather than in the gateway's already
12
+ * over-long module scope (switchroom#2996 anti-inflation ratchet).
13
+ *
14
+ * gateway.ts therefore supplies only the raw Bot API seam + the two live state
15
+ * readers; the policy lives here and is unit-testable.
16
+ */
17
+
18
+ import {
19
+ createStalePinSweeper,
20
+ unexpiredStoreRepinIds,
21
+ type StalePinSweeper,
22
+ type SweepStoreFsSeam,
23
+ } from './stale-pin-sweep.js'
24
+ import type { PersistedStatusPin } from './status-pin-store.js'
25
+ import type { StatusPinClaim } from './status-pin-retarget.js'
26
+
27
+ /**
28
+ * The minimal Telegram surface the drain needs, expressed so the gateway hands
29
+ * over only its bot handle and its retry wrapper. Building the seam HERE (not
30
+ * in gateway.ts) keeps the four raw pin/unpin calls next to the comments that
31
+ * justify them.
32
+ */
33
+ export interface StalePinSweepBotSeam {
34
+ /**
35
+ * Live grammY-shaped handle. A getter, because `lockedBot` is assigned late
36
+ * (inside `initGatewayBot`) and the sweep may outlive a rebind.
37
+ *
38
+ * Named `handle`, not `bot`: gateway.ts is statically scanned for
39
+ * module-scope identifiers named `bot`/`lockedBot`
40
+ * (`gateway-bot-construction-deferral.test.ts`, #2996 P0b), and a property
41
+ * KEY called `bot` at module scope trips that guard even though the value is
42
+ * a deferred arrow.
43
+ */
44
+ handle: () => {
45
+ api: {
46
+ getChat: (chatId: string) => Promise<unknown>
47
+ pinChatMessage: (
48
+ chatId: string,
49
+ messageId: number,
50
+ opts: { disable_notification: boolean },
51
+ ) => Promise<unknown>
52
+ unpinChatMessage: (chatId: string, messageId: number) => Promise<unknown>
53
+ unpinAllForumTopicMessages: (chatId: string, threadId: number) => Promise<unknown>
54
+ getChatMember: (chatId: string, userId: number) => Promise<unknown>
55
+ }
56
+ botInfo?: { id?: number }
57
+ }
58
+ /** The gateway's retry/telemetry envelope (`robustApiCall`). */
59
+ call: <T>(fn: () => Promise<T>, meta: { chat_id: string; verb: string }) => Promise<T>
60
+ }
61
+
62
+ export interface StalePinSweepWiring {
63
+ telegram: StalePinSweepBotSeam
64
+ /** Live in-memory status-pin claims (the single claim registry). */
65
+ claims: () => Iterable<StatusPinClaim>
66
+ /** Durable pin rows, read LIVE. Returns [] when persistence is off. Never
67
+ * throws — a read failure is logged by the caller and treated as []. */
68
+ loadPinRows: () => PersistedStatusPin[]
69
+ /** Mutex-won AND bot-constructed. False ⇒ the drain must not write. */
70
+ eligible: () => boolean
71
+ store: { path: string; fs: SweepStoreFsSeam }
72
+ /** Per-deployment override of `UNPIN_ALL_FORUM_TOPIC_ENABLED`; undefined =
73
+ * take the standing policy (the wholesale topic drain stays OFF). */
74
+ allowUnpinAllForumTopic?: boolean
75
+ log?: (line: string) => void
76
+ now?: () => number
77
+ sleep?: (ms: number) => Promise<void>
78
+ }
79
+
80
+ /**
81
+ * Pins that must SURVIVE a drain of `chatId`: the live in-memory claims for
82
+ * that chat, unioned with the deliberately-retained durable rows (an unexpired
83
+ * `tool:` pin survives `statusPinBootCleanup` by design and must survive this
84
+ * too).
85
+ *
86
+ * Read from BOTH sources because neither alone is complete at both call sites:
87
+ * during the boot sweep the in-memory maps are still empty, and during a later
88
+ * first-inbound sweep the store may lag a claim taken this session.
89
+ */
90
+ export function protectedPinIds(args: {
91
+ chatId: string
92
+ claims: Iterable<StatusPinClaim>
93
+ loadPinRows: () => PersistedStatusPin[]
94
+ now: number
95
+ log: (line: string) => void
96
+ }): number[] {
97
+ const ids: number[] = []
98
+ for (const claim of args.claims) {
99
+ if (claim.chatId === args.chatId) ids.push(claim.messageId)
100
+ }
101
+ try {
102
+ ids.push(...unexpiredStoreRepinIds(args.loadPinRows(), args.chatId, args.now))
103
+ } catch (err) {
104
+ args.log(
105
+ `telegram gateway: stale-pin-sweep: store repin scan failed ` +
106
+ `(chat=${args.chatId}): ${(err as Error).message}\n`,
107
+ )
108
+ }
109
+ return ids
110
+ }
111
+
112
+ /**
113
+ * The message ids the gateway is ON RECORD as having pinned in one
114
+ * `(chat, thread)` target — the only ids the group drain is allowed to unpin.
115
+ *
116
+ * Matched on the FULL target key, not the chat alone: the pin stack is
117
+ * chat-wide, but the obligation is per topic, and unpinning another topic's
118
+ * recorded pin while sweeping this one would clear a card that is still live.
119
+ */
120
+ export function recordedPinIdsFor(
121
+ rows: readonly PersistedStatusPin[],
122
+ chatId: string,
123
+ threadId?: number,
124
+ ): number[] {
125
+ const out: number[] = []
126
+ for (const r of rows) {
127
+ if (r.chatId !== chatId) continue
128
+ if ((r.threadId ?? undefined) !== threadId) continue
129
+ if (typeof r.messageId === 'number' && !out.includes(r.messageId)) out.push(r.messageId)
130
+ }
131
+ return out
132
+ }
133
+
134
+ /** Assemble the gateway's stale-pin sweeper. */
135
+ export function createGatewayStalePinSweeper(w: StalePinSweepWiring): StalePinSweeper {
136
+ const log = w.log ?? ((line: string) => process.stderr.write(line))
137
+ const now = w.now ?? Date.now
138
+ const { handle: bot, call } = w.telegram
139
+ return createStalePinSweeper({
140
+ getTopPinnedMessageId: async (chatId) => {
141
+ const chat = (await call(() => bot().api.getChat(chatId), {
142
+ chat_id: chatId,
143
+ verb: 'stale-pin-sweep.get-chat',
144
+ })) as { pinned_message?: { message_id?: number } } | undefined
145
+ return chat?.pinned_message?.message_id ?? null
146
+ },
147
+ // The RE-PIN half of the only sequence that actually pops a stack entry (a
148
+ // bare unpin on a stale entry is a silent no-op). An orphan pin has no
149
+ // claim to reconcile through, so the unified path cannot express it.
150
+ pinSilent: (chatId, messageId) =>
151
+ call(
152
+ // allow-raw-pin: orphan repin — see above. allow-raw-bot-api: already
153
+ // inside the gateway's robustApiCall envelope.
154
+ () => bot().api.pinChatMessage(chatId, messageId, { disable_notification: true }),
155
+ { chat_id: chatId, verb: 'stale-pin-sweep.repin' },
156
+ ),
157
+ // The UNPIN half of the pop. Its result is deliberately ignored — ok:true
158
+ // proves nothing; progress is read back from getChat.
159
+ unpin: (chatId, messageId) =>
160
+ call(
161
+ // allow-raw-pin: orphan unpin — see above. allow-raw-bot-api: already
162
+ // inside the gateway's robustApiCall envelope.
163
+ () => bot().api.unpinChatMessage(chatId, messageId),
164
+ { chat_id: chatId, verb: 'stale-pin-sweep.unpin' },
165
+ ),
166
+ unpinAllForumTopicMessages: (chatId, threadId) =>
167
+ call(() => bot().api.unpinAllForumTopicMessages(chatId, threadId), {
168
+ chat_id: chatId,
169
+ verb: 'stale-pin-sweep.unpin-all-topic',
170
+ }),
171
+ // Rights are checked before ANY write in a group. Telegram is HONEST about
172
+ // missing pin rights (400 "not enough rights to manage pinned messages"),
173
+ // but the precheck keeps a rights-less bot from emitting doomed traffic.
174
+ canPinInChat: async (chatId) => {
175
+ const self = bot().botInfo?.id
176
+ if (self == null) return false
177
+ const member = (await call(() => bot().api.getChatMember(chatId, self), {
178
+ chat_id: chatId,
179
+ verb: 'stale-pin-sweep.get-chat-member',
180
+ })) as { status?: string; can_pin_messages?: boolean } | undefined
181
+ return member?.status === 'administrator' && member.can_pin_messages === true
182
+ },
183
+ recordedPinIds: (chatId, threadId) => {
184
+ try {
185
+ return recordedPinIdsFor(w.loadPinRows(), chatId, threadId)
186
+ } catch (err) {
187
+ log(
188
+ `telegram gateway: stale-pin-sweep: recorded-pin scan failed ` +
189
+ `(chat=${chatId}): ${(err as Error).message}\n`,
190
+ )
191
+ return []
192
+ }
193
+ },
194
+ protectedMessageIds: (chatId) =>
195
+ protectedPinIds({
196
+ chatId,
197
+ claims: w.claims(),
198
+ loadPinRows: w.loadPinRows,
199
+ now: now(),
200
+ log,
201
+ }),
202
+ eligible: w.eligible,
203
+ sleep: w.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))),
204
+ now,
205
+ store: w.store,
206
+ // Passed through UNCOERCED: undefined means "take the standing policy"
207
+ // (UNPIN_ALL_FORUM_TOPIC_ENABLED), not the same as an explicit false.
208
+ allowUnpinAllForumTopic: w.allowUnpinAllForumTopic,
209
+ log,
210
+ })
211
+ }