switchroom 0.19.25 → 0.19.27

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 (84) hide show
  1. package/bin/git-agent-attribution-hook.sh +144 -0
  2. package/dist/agent-scheduler/index.js +61 -2
  3. package/dist/auth-broker/index.js +125 -8
  4. package/dist/cli/notion-write-pretool.mjs +61 -2
  5. package/dist/cli/switchroom.js +2347 -1104
  6. package/dist/host-control/main.js +126 -9
  7. package/dist/vault/approvals/kernel-server.js +124 -8
  8. package/dist/vault/broker/server.js +124 -8
  9. package/package.json +6 -2
  10. package/profiles/_base/cron-session.sh.hbs +14 -0
  11. package/profiles/_base/start.sh.hbs +145 -4
  12. package/telegram-plugin/card-layout.ts +328 -0
  13. package/telegram-plugin/dist/bridge/bridge.js +93 -1
  14. package/telegram-plugin/dist/gateway/gateway.js +2213 -1204
  15. package/telegram-plugin/dist/server.js +96 -1
  16. package/telegram-plugin/edit-flood-fuse.ts +637 -56
  17. package/telegram-plugin/flood-429-ledger.ts +526 -0
  18. package/telegram-plugin/flood-circuit-breaker.ts +18 -0
  19. package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
  20. package/telegram-plugin/gateway/gateway.ts +103 -112
  21. package/telegram-plugin/gateway/narrative-lane.ts +14 -0
  22. package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
  23. package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
  24. package/telegram-plugin/gateway/periodic-sweep-guard.ts +86 -0
  25. package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
  26. package/telegram-plugin/gateway/status-pin-retarget.ts +180 -0
  27. package/telegram-plugin/gateway/status-pin-store.ts +58 -9
  28. package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
  29. package/telegram-plugin/llm-error-present.ts +61 -2
  30. package/telegram-plugin/model-unavailable.ts +8 -0
  31. package/telegram-plugin/operator-events.ts +72 -5
  32. package/telegram-plugin/outbound-class.ts +81 -0
  33. package/telegram-plugin/provider-credit.ts +237 -0
  34. package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
  35. package/telegram-plugin/send-gate.ts +24 -2
  36. package/telegram-plugin/status-no-truncate.ts +11 -0
  37. package/telegram-plugin/status-pin-driver.ts +33 -17
  38. package/telegram-plugin/status-pin.ts +51 -5
  39. package/telegram-plugin/tests/card-golden.test.ts +69 -0
  40. package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
  41. package/telegram-plugin/tests/card-type-distinguishability.test.ts +291 -0
  42. package/telegram-plugin/tests/card-variants.golden.txt +211 -0
  43. package/telegram-plugin/tests/card-variants.ts +366 -0
  44. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +316 -0
  45. package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
  46. package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
  47. package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
  48. package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
  49. package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
  50. package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
  51. package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
  52. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
  53. package/telegram-plugin/tests/periodic-sweep-guard.test.ts +151 -0
  54. package/telegram-plugin/tests/pinned-card-collapse.test.ts +24 -18
  55. package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
  56. package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
  57. package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
  58. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
  59. package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
  60. package/telegram-plugin/tests/status-pin-retarget.test.ts +244 -0
  61. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
  62. package/telegram-plugin/tests/status-pin-shutdown-wiring.test.ts +94 -0
  63. package/telegram-plugin/tests/status-pin-store.test.ts +179 -64
  64. package/telegram-plugin/tests/status-pin.test.ts +184 -7
  65. package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
  66. package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
  67. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +29 -19
  68. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
  69. package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
  70. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  71. package/telegram-plugin/tier-downgrade.ts +3 -2
  72. package/telegram-plugin/tool-activity-summary.ts +239 -322
  73. package/telegram-plugin/uat/assertions.ts +33 -3
  74. package/telegram-plugin/uat/feed-matcher.test.ts +36 -0
  75. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +9 -2
  76. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +9 -2
  77. package/telegram-plugin/worker-activity-feed.ts +109 -30
  78. package/vendor/hindsight-memory/CLAUDE.md +45 -0
  79. package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
  80. package/vendor/hindsight-memory/scripts/recall.py +176 -7
  81. package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
  82. package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
  83. package/vendor/hindsight-memory/scripts/tests/test_recall_request_timeout.py +241 -0
  84. package/vendor/hindsight-memory/settings.json +1 -1
@@ -0,0 +1,168 @@
1
+ /**
2
+ * flood-reply-queue.ts — durably queue a USER-FACING reply that Telegram's
3
+ * flood window refused, instead of throwing its content away.
4
+ *
5
+ * The hole this closes (#3861). `retryApiCall` short-circuits BEFORE the wire
6
+ * when the persisted breaker reports a window longer than its in-process sleep
7
+ * ceiling, throwing the `FLOOD_WAIT_ACTIVE` marker (`retry-api-call.ts:198`,
8
+ * `:252`). That is the right call for the WIRE — nothing can succeed while the
9
+ * ban is open — but it is a bare `throw`, and the reply path's chunk-loop catch
10
+ * only re-wrapped it:
11
+ *
12
+ * reply failed after 0 of 1 chunk(s) sent: FLOOD_WAIT_ACTIVE
13
+ *
14
+ * The composed answer died in that string. Reproduced live on 2026-07-28: the
15
+ * error came back to the MCP caller and NO record appeared in the agent's
16
+ * `telegram/outbox/`. The durable-outbox work-loss guarantee was real for the
17
+ * outbox-delivered paths and false for the interactive reply path — so during a
18
+ * multi-hour ban the agent's actual answers to the operator were the one class
19
+ * of content that was silently lost.
20
+ *
21
+ * What this module does: classify the failure, and for anything that is not
22
+ * droppable (`cosmetic`) write the text into the SAME durable outbox the sweep
23
+ * already drains, under a CONTENT-derived nonce. The sweep (which since #3854
24
+ * defers while the window is open) delivers it when the window closes.
25
+ *
26
+ * Two design choices worth stating, because both are load-bearing:
27
+ *
28
+ * 1. CONTENT-derived nonce, not the turn nonce. `writeOutboxRecordAtomic` is
29
+ * idempotent per nonce and the sweep's delivered-keys journal is keyed by
30
+ * nonce, so a content nonce gives caller-retry dedup (same text ⇒ same file
31
+ * ⇒ one delivery, and after delivery the journal makes a re-queue a no-op)
32
+ * WITHOUT inheriting the turn nonce's failure mode: an earlier successful
33
+ * reply in the same turn journals `turn.turnId` as delivered, which would
34
+ * make a later flood-blocked answer queued under that same nonce
35
+ * `skip-journaled` — i.e. dropped. Never drop the answer.
36
+ *
37
+ * 2. `cosmetic` is NOT queued. A typing indicator or a card edit that missed
38
+ * its moment is worthless (and actively confusing) delivered hours later;
39
+ * the priority classes already in the tree say so. `critical` / `useful` /
40
+ * untagged are queued.
41
+ */
42
+
43
+ import { sha256Hex, writeOutboxRecordAtomic, type OutboxRecord } from '../outbox.js'
44
+ import { isFloodWaitActiveError } from '../retry-api-call.js'
45
+
46
+ /** The `source` stamped on records this module writes (diagnostic only). */
47
+ export const FLOOD_QUEUED_SOURCE = 'flood-deferred-reply'
48
+
49
+ /** Priority classes as used by `retry-api-call.ts` / `outbound-class.ts`. */
50
+ export type QueuePriorityClass = 'critical' | 'useful' | 'cosmetic' | undefined
51
+
52
+ /**
53
+ * True when `err` is a Telegram flood rejection — the local `FLOOD_WAIT_ACTIVE`
54
+ * fail-fast, a raw 429, or a wrapper whose message carries either.
55
+ *
56
+ * Deliberately duck-typed as well as `instanceof`-checked: the marker error
57
+ * carries Telegram's own `{ error_code: 429, parameters: { retry_after } }`
58
+ * shape, several surfaces re-wrap it in a plain `Error`, and the reply path's
59
+ * own partial-failure contract wraps it in
60
+ * `reply failed after N of M chunk(s) sent: …`.
61
+ */
62
+ export function isFloodRejection(err: unknown): boolean {
63
+ if (isFloodWaitActiveError(err)) return true
64
+ const e = err as { error_code?: number; parameters?: { retry_after?: number } } | null
65
+ if (e != null && typeof e === 'object') {
66
+ if (e.error_code === 429) return true
67
+ if (typeof e.parameters?.retry_after === 'number') return true
68
+ }
69
+ const msg = err instanceof Error ? err.message : String(err ?? '')
70
+ if (msg.includes('FLOOD_WAIT_ACTIVE')) return true
71
+ return /too many requests/i.test(msg) && /retry[ _-]?after/i.test(msg)
72
+ }
73
+
74
+ /** Telegram's stated `retry_after` (seconds) if the error carries one. */
75
+ export function floodRetryAfterSec(err: unknown): number | null {
76
+ const e = err as { retryAfterSec?: number; parameters?: { retry_after?: number } } | null
77
+ if (e != null && typeof e === 'object') {
78
+ if (typeof e.retryAfterSec === 'number') return e.retryAfterSec
79
+ if (typeof e.parameters?.retry_after === 'number') return e.parameters.retry_after
80
+ }
81
+ const msg = err instanceof Error ? err.message : String(err ?? '')
82
+ const m = /retry[ _-]?after[^0-9]{0,4}(\d+)/i.exec(msg)
83
+ return m != null ? Number(m[1]) : null
84
+ }
85
+
86
+ /**
87
+ * Stable, content-derived outbox nonce for a flood-deferred send. Same
88
+ * chat + thread + text ⇒ same nonce ⇒ exactly one delivery however many times
89
+ * the caller retries. Prefixed so a record's provenance is obvious on disk.
90
+ */
91
+ export function floodQueueNonce(
92
+ chatId: string,
93
+ threadId: number | null,
94
+ text: string,
95
+ ): string {
96
+ return `flood-${sha256Hex(`${chatId}\u0000${threadId ?? '_'}\u0000${text}`).slice(0, 40)}`
97
+ }
98
+
99
+ export interface FloodQueueInput {
100
+ /** The error that killed the send. */
101
+ err: unknown
102
+ chatId: string
103
+ threadId: number | null
104
+ /** The UNDELIVERED text (for a partial failure, only the chunks that never landed). */
105
+ text: string
106
+ /** Priority class of the send. `cosmetic` is never queued. */
107
+ priorityClass?: QueuePriorityClass
108
+ /** Per-session origin chat, for the sweep's F2 scoped routing fallback. */
109
+ originChatId?: string | null
110
+ originThreadId?: number | null
111
+ createdAt?: number
112
+ stateDir?: string
113
+ /** Injected for tests; defaults to the real atomic outbox writer. */
114
+ write?: (record: OutboxRecord, stateDir?: string) => boolean
115
+ }
116
+
117
+ export interface FloodQueueResult {
118
+ turnNonce: string
119
+ retryAfterSec: number | null
120
+ /** Human-readable outcome for the MCP caller, so the model does NOT recompose. */
121
+ notice: string
122
+ }
123
+
124
+ /**
125
+ * Queue a flood-blocked send to the durable outbox.
126
+ *
127
+ * Returns `null` — meaning "not handled, let the caller throw" — when the error
128
+ * is not a flood rejection, when the class is `cosmetic` (legitimately
129
+ * droppable), when there is no text left to deliver, or when the disk write
130
+ * failed. A `null` return must never be mistaken for "delivered".
131
+ */
132
+ export function queueFloodBlockedReply(input: FloodQueueInput): FloodQueueResult | null {
133
+ const { err, chatId, threadId, text } = input
134
+ if (!isFloodRejection(err)) return null
135
+ if (input.priorityClass === 'cosmetic') return null
136
+ if (chatId === '' || text === '') return null
137
+
138
+ const turnNonce = floodQueueNonce(chatId, threadId, text)
139
+ const createdAt = input.createdAt ?? Date.now()
140
+ const record: OutboxRecord = {
141
+ turnNonce,
142
+ chatId,
143
+ threadId,
144
+ text,
145
+ textSha256: sha256Hex(text),
146
+ createdAt,
147
+ source: FLOOD_QUEUED_SOURCE,
148
+ ...(input.originChatId != null ? { originChatId: input.originChatId } : {}),
149
+ ...(input.originThreadId != null ? { originThreadId: input.originThreadId } : {}),
150
+ }
151
+ const write = input.write ?? writeOutboxRecordAtomic
152
+ if (!write(record, input.stateDir)) return null
153
+
154
+ const retryAfterSec = floodRetryAfterSec(err)
155
+ const when =
156
+ retryAfterSec != null && retryAfterSec > 0
157
+ ? ` Telegram's flood window has ~${retryAfterSec}s left`
158
+ : ' A Telegram flood window is open'
159
+ return {
160
+ turnNonce,
161
+ retryAfterSec,
162
+ notice:
163
+ `reply QUEUED (not yet visible to the user):${when}, so this answer was written ` +
164
+ `to the durable outbox and will be delivered automatically when the window ` +
165
+ `closes. Do NOT resend it — a resend is deduplicated, and every extra call ` +
166
+ `extends the ban. (outbox nonce ${turnNonce})`,
167
+ }
168
+ }
@@ -180,7 +180,7 @@ import {
180
180
  import { fmtLocalStamp, resolveEnvTimezone, renderLogTimestampsLocal } from '../shared/local-time.js'
181
181
  import { StatusReactionController } from '../status-reactions.js'
182
182
  import { DeferredDoneReactions } from '../reaction-defer.js'
183
- import { createWorkerActivityFeed, isWorkerActivityFeedEnabled } from '../worker-activity-feed.js'
183
+ import { createWorkerActivityFeed, isWorkerActivityFeedEnabled, workerFeedEditPriorityClass } from '../worker-activity-feed.js'
184
184
  import {
185
185
  detectMemoryLegibilityEvent,
186
186
  isMemoryLegibilityEnabled,
@@ -195,9 +195,9 @@ import {
195
195
  renderConsolidationLine,
196
196
  } from '../consolidation-legibility.js'
197
197
  import type { WebhookGatewayRecord } from '../../src/web/webhook-gateway-record.js'
198
- import { reconcilePin, type PinBotApi } from '../status-pin-driver.js'
199
- import type { PinState, DesiredPin } from '../status-pin.js'
200
- import { decidePinAction, PinRightsCache } from '../status-pin.js'
198
+ import { executePinLeg, type PinBotApi } from '../status-pin-driver.js'
199
+ import type { PinState, DesiredPin, PinLegAction } from '../status-pin.js'
200
+ import { PinRightsCache } from '../status-pin.js'
201
201
  import { formatTurnLifecycle, detectStatusSurfaceDegraded } from './status-surface-log.js'
202
202
  import { parseSourceMessageId } from './source-message-id.js'
203
203
  import {
@@ -294,7 +294,7 @@ import {
294
294
  retryWithThreadFallback,
295
295
  isFloodWaitActiveError,
296
296
  } from '../retry-api-call.js'
297
- import { installEditFloodFuse } from '../edit-flood-fuse.js'
297
+ import { installEditFloodFuse, editFloodFuseConfigFromEnv } from '../edit-flood-fuse.js'
298
298
  import { createSendGate, sendGateConfigFromEnv, isSendGateShed } from '../send-gate.js'
299
299
  import { createStatsLogger, createFloodWindowObserver } from '../send-gate-observability.js'
300
300
  import { installTgPostLogger, installRichMarkdownGuard, withTgPostTags } from '../shared/bot-runtime.js'
@@ -637,6 +637,9 @@ import type { HostdRequest, HostdResponse } from '../../src/host-control/protoco
637
637
  import type { AgentAudit } from '../welcome-text.js'
638
638
  import { shouldSweepChatAtBoot } from './boot-sweep-filter.js'
639
639
  import { createBootSweepGate, runBootPinSweepSteps } from './boot-sweep-gate.js'
640
+ import { runStatusPinReconcile, type StatusPinClaim } from './status-pin-retarget.js'
641
+ import { createPeriodicSweepGuard } from './periodic-sweep-guard.js'
642
+ import { withDeadline } from './with-deadline.js'
640
643
  import { createStatusPinApi, type PinCapableBot, type RobustApiSeam } from './status-pin-api.js'
641
644
  import {
642
645
  createDmPinSweeper,
@@ -666,11 +669,9 @@ import { buildCapturedDeliverySnapshot, createCapturedResumeDispatcher } from '.
666
669
  import {
667
670
  loadStatusPins,
668
671
  mutateStatusPinRow,
669
- reconcileAndPersistStatusPin,
670
672
  runStatusPinBootCleanup,
671
673
  withPinReconcileLock,
672
674
  type PersistedStatusPin,
673
- type StatusPinPersistOp,
674
675
  } from './status-pin-store.js'
675
676
  import {
676
677
  loadActivityCards,
@@ -690,6 +691,7 @@ import {
690
691
  } from './queued-card-store.js'
691
692
  import {
692
693
  decideWorkerPinReaps,
694
+ groupPinStatus,
693
695
  storeOnlyWorkerPinCandidates,
694
696
  WORKER_PIN_TTL_MS_DEFAULT,
695
697
  } from './worker-pin-reaper.js'
@@ -5864,7 +5866,7 @@ const redactAuthCodeApi = {
5864
5866
  * flood sleep and added a pre-call gate for LONG open windows. This wrapper
5865
5867
  * pre-dates neither mechanism nor fights them — it sits under the emitter's own
5866
5868
  * flood gate, which short-circuits earlier still (a typing ping during a ban
5867
- * never even reaches the retry layer). Defense in depth, in that order.
5869
+ * never even reaches the retry layer; #3853 adds the READ hook here too).
5868
5870
  */
5869
5871
  // No `log` hook: retryApiCall's flood line reads "waiting Ns", which would be a
5870
5872
  // lie here — we never wait, we drop. And the error the caller finally sees names
@@ -5874,7 +5876,7 @@ const redactAuthCodeApi = {
5874
5876
  // the real value is still in hand.
5875
5877
  const recordTypingFloodWait = makeFloodWaitRecorder(FLOOD_STATE_PATH)
5876
5878
  const nonEssentialApiCall = createRetryApiCall({
5877
- maxRetries: 1,
5879
+ maxRetries: 1, floodWaitRemainingMs: probeFloodWaitRemainingMs, // #3853 read side
5878
5880
  sleep: async () => {},
5879
5881
  onFloodWait: (retryAfterSec) => {
5880
5882
  recordTypingFloodWait(retryAfterSec)
@@ -8523,19 +8525,18 @@ const PIN_STATUS_WHILE_WORKING = (() => {
8523
8525
  // It does NOT touch the reply / stream_reply send handlers (the v1 bug was send
8524
8526
  // handlers unconditionally unpinning on every send) and runs NO polling
8525
8527
  // watchdog / getChat().pinned_message reconciler.
8526
- const statusPinState = new Map<string, PinState>()
8528
+ // ONE registry, keyed by pinKey → { messageId, chatId, pinnedAt } (#3809).
8529
+ // This was three parallel Maps (state / chatIds / pinnedAt), written on the
8530
+ // same commit but free to diverge; a `wk:` claim that lost its chatId entry was
8531
+ // skipped by the mid-session reaper on every pass AND excluded from the durable
8532
+ // store-orphan net, i.e. silently unreapable until the next boot. One record
8533
+ // makes the invariant structural — see StatusPinClaim in status-pin-retarget.ts.
8534
+ //
8527
8535
  // F2 serialization: same-pinKey reconciles run one-at-a-time via
8528
8536
  // `withPinReconcileLock` (status-pin-store.ts — kept there so it's
8529
8537
  // unit-testable) so each reads a fresh `prev`; see its doc for the stale-`prev`
8530
8538
  // race it closes. Adds zero Telegram API calls (a serialized noop still no-ops).
8531
- // Companion registry: pinKey → chatId, so the pre-restart sweep can unpin
8532
- // owned pins without threading the chat id through every call site. Written on
8533
- // every desired-pinned reconcile, cleared alongside the state on unpin.
8534
- const statusPinChatIds = new Map<string, string>()
8535
- // Companion registry: pinKey → wall-clock ms the claim was FIRST taken (a
8536
- // re-pin of the same key keeps the original timestamp). Feeds the TTL gate of
8537
- // the mid-session `wk:` pin reaper (#3001); cleared alongside the state.
8538
- const statusPinPinnedAt = new Map<string, number>()
8539
+ const statusPinClaims = new Map<string, StatusPinClaim>()
8539
8540
  // Rights-aware negative cache (#3024): chats where an auto status-pin attempt
8540
8541
  // failed with the permanent "not enough rights to manage pinned messages" 400.
8541
8542
  // Per-process only — a restart clears it so a later-granted pin right re-enables
@@ -8659,7 +8660,7 @@ const TOOL_PIN_TTL_MS = (() => {
8659
8660
  // mutation never rejects (persist is fail-open, load is fail-open).
8660
8661
  function persistBannerRow(row: PersistedStatusPin | null): void {
8661
8662
  if (!bannerPinPersistEnabled) return
8662
- void mutateStatusPinRow(
8663
+ void mutateStatusPinRow( // allow-raw-pin-store: banner row bookkeeping — the slot banner has its own sanctioned driver; this only mirrors its outcome into the shared store file.
8663
8664
  STATUS_PIN_STORE_PATH,
8664
8665
  statusPinStoreFs,
8665
8666
  BANNER_PIN_KEY,
@@ -8699,7 +8700,7 @@ async function statusPinBootCleanup(): Promise<void> {
8699
8700
  const { cleared, retained, kept, total } = await runStatusPinBootCleanup({
8700
8701
  path: STATUS_PIN_STORE_PATH,
8701
8702
  fs: statusPinStoreFs,
8702
- unpin: (chatId, messageId) => api.unpinChatMessage(chatId, messageId),
8703
+ unpin: (chatId, messageId) => api.unpinChatMessage(chatId, messageId), // allow-raw-pin: boot cleanup runs BEFORE any claim exists — it reconciles the durable rows themselves, so there is nothing to route through reconcileStatusPin.
8703
8704
  })
8704
8705
  if (total > 0) {
8705
8706
  process.stderr.write(
@@ -8762,7 +8763,7 @@ async function activityCardBootReaper(): Promise<void> {
8762
8763
  ),
8763
8764
  unpinCard: (record) =>
8764
8765
  robustApiCall(
8765
- () => lockedBot.api.unpinChatMessage(record.chatId, record.activityMessageId),
8766
+ () => lockedBot.api.unpinChatMessage(record.chatId, record.activityMessageId), // allow-raw-pin: activity-card boot reaper — unpins a card recovered from the CARD store, which has no status-pin claim to reconcile through.
8766
8767
  {
8767
8768
  chat_id: record.chatId,
8768
8769
  ...(record.threadId != null ? { threadId: record.threadId } : {}),
@@ -8941,12 +8942,12 @@ async function runMidSessionCardReaper(): Promise<void> {
8941
8942
  // claim is tracked (e.g. claim already dropped out-of-band).
8942
8943
  unpinCard: async (record) => {
8943
8944
  const pinKey = `fg:${record.turnKey}`
8944
- if (statusPinState.has(pinKey)) {
8945
+ if (statusPinClaims.has(pinKey)) {
8945
8946
  await reconcileStatusPin(pinKey, record.chatId, { pinned: false })
8946
8947
  return true
8947
8948
  }
8948
8949
  return robustApiCall(
8949
- () => lockedBot.api.unpinChatMessage(record.chatId, record.activityMessageId),
8950
+ () => lockedBot.api.unpinChatMessage(record.chatId, record.activityMessageId), // allow-raw-pin: activity-card mid-session reaper — same as the boot reaper: a card-store record, not a status-pin claim.
8950
8951
  {
8951
8952
  chat_id: record.chatId,
8952
8953
  ...(record.threadId != null ? { threadId: record.threadId } : {}),
@@ -8974,17 +8975,12 @@ async function runMidSessionCardReaper(): Promise<void> {
8974
8975
  // the durable store row clear together.
8975
8976
  if (WORKER_PIN_REAPER_ENABLED && PIN_STATUS_WHILE_WORKING) {
8976
8977
  try {
8977
- const inMemoryKeys = new Set(
8978
- [...statusPinState.keys()].filter((k) => k.startsWith('wk:')),
8979
- )
8980
- const inMemoryCandidates = [...inMemoryKeys].map((k) => ({
8981
- pinKey: k,
8982
- chatId: statusPinChatIds.get(k) ?? '',
8983
- // A missing timestamp (should not happen — set on every claim)
8984
- // degrades to "claimed just now": terminality can still reap it,
8985
- // the TTL gate never can. Conservative, never a spurious unpin.
8986
- pinnedAt: statusPinPinnedAt.get(k) ?? now,
8987
- }))
8978
+ // One record per claim (#3809): chatId and pinnedAt are fields of the
8979
+ // claim, so neither can go missing and strand the key as unreapable.
8980
+ const inMemoryCandidates = [...statusPinClaims.entries()]
8981
+ .filter(([k]) => k.startsWith('wk:'))
8982
+ .map(([pinKey, claim]) => ({ pinKey, chatId: claim.chatId, pinnedAt: claim.pinnedAt }))
8983
+ const inMemoryKeys = new Set(inMemoryCandidates.map((c) => c.pinKey))
8988
8984
  // #3001 durable group net: fold in `wk:` rows that live in the DURABLE
8989
8985
  // store but have NO in-memory claim — the divergence window where the
8990
8986
  // claim was lost but the Telegram pin + store row survive. These would
@@ -9011,8 +9007,11 @@ async function runMidSessionCardReaper(): Promise<void> {
9011
9007
  // missed unpin → 'terminal' (reap now). The feed's own group-empty
9012
9008
  // unpin is the primary path; this is the missed-unpin backstop.
9013
9009
  if (agentId.startsWith('group:')) {
9014
- const feedKey = agentId.slice('group:'.length)
9015
- return workerActivityFeed?.hasRunningInFeed(feedKey) ? 'running' : 'terminal'
9010
+ // groupPinStatus distinguishes "no feed to ask" (→ 'unknown', TTL
9011
+ // only) from "the feed says this group is done" (→ 'terminal',
9012
+ // reap now). The `?.` shorthand conflated them and could unpin a
9013
+ // LIVE group pin during a feed-null window (#3811).
9014
+ return groupPinStatus(workerActivityFeed, agentId.slice('group:'.length))
9016
9015
  }
9017
9016
  if (turnsDb == null) return 'unknown'
9018
9017
  try {
@@ -9043,7 +9042,7 @@ async function runMidSessionCardReaper(): Promise<void> {
9043
9042
  // are best-effort/idempotent; a failed unpin still drops the row so
9044
9043
  // the next boot's cleanup is the final backstop.
9045
9044
  try {
9046
- await statusPinApi().unpinChatMessage(reap.chatId, reap.messageId)
9045
+ await statusPinApi().unpinChatMessage(reap.chatId, reap.messageId) // allow-raw-pin: store-orphan reap — by definition there is NO in-memory claim, so this unpins the exact tracked message id (group-safe, never unpin-all).
9047
9046
  } catch (err) {
9048
9047
  process.stderr.write(
9049
9048
  `telegram gateway: worker-pin reaper store-orphan unpin failed ` +
@@ -9051,7 +9050,7 @@ async function runMidSessionCardReaper(): Promise<void> {
9051
9050
  `${(err as Error).message}\n`,
9052
9051
  )
9053
9052
  }
9054
- await mutateStatusPinRow(
9053
+ await mutateStatusPinRow( // allow-raw-pin-store: drops the row for the store orphan unpinned immediately above; there is no claim to reconcile.
9055
9054
  STATUS_PIN_STORE_PATH,
9056
9055
  statusPinStoreFs,
9057
9056
  reap.pinKey,
@@ -9069,8 +9068,20 @@ async function runMidSessionCardReaper(): Promise<void> {
9069
9068
  }
9070
9069
  }
9071
9070
 
9071
+ // Single-flight: a pass awaits Telegram calls that can park behind a flood-wait
9072
+ // for longer than the interval, so ticks fanned out. See periodic-sweep-guard.ts.
9073
+ const midSessionReaperGuard = createPeriodicSweepGuard({
9074
+ run: runMidSessionCardReaper,
9075
+ onSkip: () => process.stderr.write(
9076
+ 'telegram gateway: mid-session reaper tick skipped — previous pass still running\n',
9077
+ ),
9078
+ onError: (err) => process.stderr.write(
9079
+ `telegram gateway: mid-session reaper pass threw: ${(err as Error).message}\n`,
9080
+ ),
9081
+ })
9082
+
9072
9083
  const midSessionCardReaper = isGatewayMain ? setInterval(() => { // #2996 P0c gate
9073
- void runMidSessionCardReaper()
9084
+ void midSessionReaperGuard.tick()
9074
9085
  }, MID_SESSION_CARD_REAPER_INTERVAL_MS) : undefined
9075
9086
  midSessionCardReaper?.unref()
9076
9087
 
@@ -9129,14 +9140,15 @@ async function reconcileStatusPinInner(
9129
9140
  // settled — always the true current claim. Closes the stale-`prev` race (a
9130
9141
  // turn-end clear dropping the disk row under a flood-delayed open-pin) and the
9131
9142
  // older duplicate-pin concern (two edits both reading prev=null).
9132
- const prev = statusPinState.get(pinKey) ?? null
9143
+ const claim = statusPinClaims.get(pinKey)
9144
+ const prev: PinState | null = claim == null ? null : { messageId: claim.messageId }
9133
9145
 
9134
- const runReconcile = () =>
9135
- reconcilePin({
9146
+ const runReconcileFrom = (action: PinLegAction, from: PinState | null) =>
9147
+ executePinLeg({
9136
9148
  api: statusPinApi(),
9137
9149
  chatId,
9138
- prevState: prev,
9139
- desired,
9150
+ prevState: from,
9151
+ action,
9140
9152
  rightsCache: statusPinRightsCache,
9141
9153
  onPinRightsDisabled: (chat) => {
9142
9154
  // Logged ONCE per chat per process (#3024). Every subsequent auto-pin
@@ -9155,53 +9167,19 @@ async function reconcileStatusPinInner(
9155
9167
  },
9156
9168
  })
9157
9169
 
9158
- // Classify the action so we persist INTENT before the pin API call. Only a
9159
- // fresh `pin` of a message that isn't already our claim opens the leak window
9160
- // (the API call actually pins something new); everything else (unpin, noop,
9161
- // re-pin of the same id) clears / leaves the record and is safe to persist
9162
- // after. See reconcileAndPersistStatusPin for the ordering rationale.
9163
- const action = decidePinAction(prev, desired)
9164
- const op: StatusPinPersistOp =
9165
- action.kind === 'pin'
9166
- ? { kind: 'pin', messageId: action.messageId }
9167
- : { kind: 'clear' }
9168
-
9169
- if (!statusPinPersistEnabled) {
9170
- // Persistence off (STATIC / feature-off): just reconcile + update Maps.
9171
- const next = await runReconcile()
9172
- if (next == null) {
9173
- statusPinState.delete(pinKey)
9174
- statusPinChatIds.delete(pinKey)
9175
- statusPinPinnedAt.delete(pinKey)
9176
- } else {
9177
- statusPinState.set(pinKey, next)
9178
- statusPinChatIds.set(pinKey, chatId)
9179
- if (!statusPinPinnedAt.has(pinKey)) statusPinPinnedAt.set(pinKey, Date.now())
9180
- }
9181
- return
9182
- }
9183
-
9184
- // Persist-BEFORE-pin ordering lives in reconcileAndPersistStatusPin: for a
9185
- // pin it writes a `pending` record first, then confirms it after the API call
9186
- // lands (or drops it on failure). A crash in the window leaves a pending
9187
- // record boot cleanup will unpin. In-memory Maps are updated from the result.
9188
- const next = await reconcileAndPersistStatusPin({
9189
- path: STATUS_PIN_STORE_PATH,
9190
- fs: statusPinStoreFs,
9170
+ // Persist ordering + the RETARGET two-leg expansion: status-pin-retarget.ts.
9171
+ await runStatusPinReconcile({
9191
9172
  pinKey,
9192
9173
  chatId,
9193
- op,
9194
- applyPin: runReconcile,
9174
+ prev,
9175
+ desired,
9176
+ // persist:null ⇒ no durable row (STATIC / feature-off).
9177
+ persist: statusPinPersistEnabled
9178
+ ? { path: STATUS_PIN_STORE_PATH, fs: statusPinStoreFs }
9179
+ : null,
9180
+ runPin: runReconcileFrom,
9181
+ claims: statusPinClaims,
9195
9182
  })
9196
- if (next == null) {
9197
- statusPinState.delete(pinKey)
9198
- statusPinChatIds.delete(pinKey)
9199
- statusPinPinnedAt.delete(pinKey)
9200
- } else {
9201
- statusPinState.set(pinKey, next)
9202
- statusPinChatIds.set(pinKey, chatId)
9203
- if (!statusPinPinnedAt.has(pinKey)) statusPinPinnedAt.set(pinKey, Date.now())
9204
- }
9205
9183
  }
9206
9184
 
9207
9185
  // #3207: the per-worker `reconcileWorkerPin(agentId, …)` (keyed `wk:<agentId>`)
@@ -9217,14 +9195,10 @@ async function reconcileStatusPinInner(
9217
9195
  * crash / interrupt never leaves a permanent pin behind. Best-effort;
9218
9196
  * clears the claim regardless of the unpin outcome (drop-on-unpin). */
9219
9197
  async function unpinAllStatusPins(): Promise<void> {
9220
- const keys = [...statusPinState.keys()]
9221
- for (const key of keys) {
9222
- const st = statusPinState.get(key)
9223
- if (st == null) continue
9224
- // Recover the chat id from the state map's companion key registry.
9225
- const chatId = statusPinChatIds.get(key)
9226
- if (chatId == null) { statusPinState.delete(key); statusPinPinnedAt.delete(key); continue }
9227
- await reconcileStatusPin(key, chatId, { pinned: false })
9198
+ // The chat id is a FIELD of the claim (#3809), so every claim is unpinnable —
9199
+ // there is no "claim we know about but cannot address" branch to fall through.
9200
+ for (const [key, claim] of [...statusPinClaims.entries()]) {
9201
+ await reconcileStatusPin(key, claim.chatId, { pinned: false })
9228
9202
  }
9229
9203
  }
9230
9204
 
@@ -9243,14 +9217,14 @@ async function unpinAllStatusPins(): Promise<void> {
9243
9217
  let dmPinSweepEligible = false
9244
9218
  const dmPinSweeper: DmPinSweeper = createDmPinSweeper({
9245
9219
  unpinAll: (chatId) =>
9246
- robustApiCall(() => lockedBot.api.unpinAllChatMessages(chatId), {
9220
+ robustApiCall(() => lockedBot.api.unpinAllChatMessages(chatId), { // allow-raw-pin: DM-only boot sweep — clears pins this process cannot enumerate (pre-restart orphans) and immediately re-pins the live tracked ids below.
9247
9221
  chat_id: chatId,
9248
9222
  verb: 'dm-pin-sweep.unpin-all',
9249
9223
  }),
9250
9224
  pinSilent: (chatId, messageId) =>
9251
9225
  robustApiCall(
9252
9226
  () =>
9253
- lockedBot.api.pinChatMessage(chatId, messageId, {
9227
+ lockedBot.api.pinChatMessage(chatId, messageId, { // allow-raw-pin: the re-pin half of the DM boot sweep — restores ids the sweep just cleared; the claims themselves are untouched.
9254
9228
  disable_notification: true,
9255
9229
  }),
9256
9230
  { chat_id: chatId, verb: 'dm-pin-sweep.repin' },
@@ -9265,8 +9239,8 @@ const dmPinSweeper: DmPinSweeper = createDmPinSweeper({
9265
9239
  // degrades to in-memory-only. The sweeper dedupes.
9266
9240
  liveTrackedMessageIds: (chatId) => {
9267
9241
  const ids: number[] = []
9268
- for (const [key, st] of statusPinState.entries()) {
9269
- if (statusPinChatIds.get(key) === chatId) ids.push(st.messageId)
9242
+ for (const claim of statusPinClaims.values()) {
9243
+ if (claim.chatId === chatId) ids.push(claim.messageId)
9270
9244
  }
9271
9245
  if (statusPinPersistEnabled || bannerPinPersistEnabled || toolPinPersistEnabled) {
9272
9246
  try {
@@ -13358,7 +13332,7 @@ async function executePinMessage(args: Record<string, unknown>): Promise<unknown
13358
13332
  // failure the agent should see.
13359
13333
  const pinMsgId = Number(args.message_id)
13360
13334
  await robustApiCall(
13361
- () => lockedBot.api.pinChatMessage(pinChatId, pinMsgId),
13335
+ () => lockedBot.api.pinChatMessage(pinChatId, pinMsgId), // allow-raw-pin: MCP `pin_message` tool — an explicit, agent-requested pin of an arbitrary message, not a progress surface with a claim.
13362
13336
  { chat_id: pinChatId, verb: 'pin_message' },
13363
13337
  )
13364
13338
  // An explicit pin succeeded here, so the bot demonstrably HAS pin rights in
@@ -13375,7 +13349,7 @@ async function executePinMessage(args: Record<string, unknown>): Promise<unknown
13375
13349
  // failure must never fail the tool call the pin already landed for.
13376
13350
  if (toolPinPersistEnabled) {
13377
13351
  const toolPinKey = `tool:${pinChatId}:${pinMsgId}`
13378
- void mutateStatusPinRow(STATUS_PIN_STORE_PATH, statusPinStoreFs, toolPinKey, {
13352
+ void mutateStatusPinRow(STATUS_PIN_STORE_PATH, statusPinStoreFs, toolPinKey, { // allow-raw-pin-store: records the TTL-scoped `tool:` row for the explicit pin above so the boot sweep can expire it.
13379
13353
  pinKey: toolPinKey,
13380
13354
  chatId: pinChatId,
13381
13355
  messageId: pinMsgId,
@@ -18053,7 +18027,7 @@ function broadcastTierNotice(markdown: string): void {
18053
18027
  * the user must re-issue `/model <premium>`, which the notice says plainly.
18054
18028
  * - Effort is NATIVE: no `.session-effort` carrier is written, so the restart
18055
18029
  * sheds any live /effort override and the downgraded default boots at the
18056
- * configured `thinking_effort` (the fleet `low` pin, #1978).
18030
+ * configured `thinking_effort` (#1978 / thinking-effort-risk.ts).
18057
18031
  * - Loop-bounded by the NATURAL on-default guard: after the downgrade boot the
18058
18032
  * session runs the configured default (override gone), so a re-entry returns
18059
18033
  * `skip` ('on-default') and never re-downgrades — even if the default is
@@ -22366,8 +22340,7 @@ const voiceHandlerDeps: VoiceHandlerDeps = {
22366
22340
  // site (check-bot-api-wrapping stays satisfied — the raw call remains in
22367
22341
  // gateway.ts, already inside the retry policy).
22368
22342
  const pinnedMessageHandlerDeps: PinnedMessageHandlerDeps = {
22369
- statusPinState,
22370
- statusPinChatIds,
22343
+ statusPinClaims,
22371
22344
  deleteServiceMessage: (chatId, serviceMsgId) =>
22372
22345
  robustApiCall(
22373
22346
  () => lockedBot.api.deleteMessage(chatId, serviceMsgId),
@@ -22750,6 +22723,24 @@ async function shutdown(signal: string): Promise<void> {
22750
22723
  agentName,
22751
22724
  })
22752
22725
 
22726
+ // Status-pin: unpin everything we own before we exit. `sweepBeforeSelfRestart`
22727
+ // covered the gateway's OWN restart verbs, but SIGTERM/SIGINT — `docker
22728
+ // restart`, a compose bounce, a host reboot: the common path — reached
22729
+ // `process.exit(0)` with every live pin still pinned, leaving it at the top of
22730
+ // the chat until the NEXT boot's cleanup won the mutex and built a bot
22731
+ // (empirically: overlord logged `cleared 1/1 orphaned pin(s) from a prior
22732
+ // session` on a routine 2026-07-27 restart). Runs after the drain (no live
22733
+ // turn to race) and before `bot.api` goes away — `bot.stop()` only stops
22734
+ // polling. Deadline-bounded on purpose: a flood-wait-parked unpin must not
22735
+ // push shutdown into the `forceExitTimer` path, which skips the lock release.
22736
+ try {
22737
+ await withDeadline(unpinAllStatusPins(), 5_000, 'status-pin shutdown sweep timed out')
22738
+ } catch (err) {
22739
+ process.stderr.write(
22740
+ `telegram gateway: shutdown status-pin sweep incomplete: ${(err as Error).message}\n`,
22741
+ )
22742
+ }
22743
+
22753
22744
  // Now finish the cleanup the drain didn't touch.
22754
22745
  inboundCoalescer.reset()
22755
22746
  pendingReauthFlows.clear()
@@ -22964,8 +22955,8 @@ async function initGatewayBot(): Promise<void> {
22964
22955
  // outbound call can bypass (grammY has no route to the network that skips the
22965
22956
  // transformer stack). Kill-switch SWITCHROOM_EDIT_FUSE=0; see edit-flood-fuse.ts.
22966
22957
  installEditFloodFuse(bot, {
22967
- enabled: process.env.SWITCHROOM_EDIT_FUSE !== '0',
22968
- onTrip: (i) => process.stderr.write(`edit-flood-fuse ${i.action} method=${i.method} key=${i.key}\n`),
22958
+ ...editFloodFuseConfigFromEnv(process.env),
22959
+ onTrip: (i) => process.stderr.write(`edit-flood-fuse ${i.action} method=${i.method} key=${i.key} class=${i.cls}\n`),
22969
22960
  })
22970
22961
 
22971
22962
  // Diagnostic update tap (#3300): one compact line per received update, logged
@@ -23985,7 +23976,7 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
23985
23976
  )
23986
23977
  return sent as { message_id: number }
23987
23978
  },
23988
- editMessageText: (cid, mid, text, editOpts) =>
23979
+ editMessageText: (cid, mid, text, editOpts, editMeta) =>
23989
23980
  robustApiCall(
23990
23981
  () =>
23991
23982
  lockedBot.api.editMessageText(
@@ -23994,12 +23985,12 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
23994
23985
  richMessage(text),
23995
23986
  editOpts as Parameters<typeof lockedBot.api.editMessageText>[3],
23996
23987
  ),
23997
- // Worker-feed EDITs are COSMETIC — pass messageId/editPayload
23998
- // so the per-message floor + coalescing + no-op skip engage.
23988
+ // Repaints COSMETIC, terminal frame USEFUL (#3848); messageId/
23989
+ // editPayload engage the floor + coalescing + no-op skip.
23999
23990
  {
24000
23991
  chat_id: cid,
24001
23992
  verb: 'worker-feed',
24002
- priorityClass: 'cosmetic',
23993
+ priorityClass: workerFeedEditPriorityClass(editMeta),
24003
23994
  messageId: mid,
24004
23995
  editPayload: text,
24005
23996
  },
@@ -24057,7 +24048,7 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
24057
24048
  if (messageId != null) {
24058
24049
  void reconcileStatusPin(key, chatId, { pinned: true, messageId })
24059
24050
  } else {
24060
- const unpinChat = chatId || statusPinChatIds.get(key)
24051
+ const unpinChat = chatId || statusPinClaims.get(key)?.chatId
24061
24052
  if (unpinChat != null && unpinChat.length > 0) {
24062
24053
  void reconcileStatusPin(key, unpinChat, { pinned: false })
24063
24054
  }
@@ -838,6 +838,20 @@ export function createNarrativeLane(deps: NarrativeLaneDeps) {
838
838
  id,
839
839
  )
840
840
  }
841
+ // #3812 — release the status-pin claim BEFORE the message goes away,
842
+ // symmetric with the durable card-record drop above. `turn-end.ts` also
843
+ // unpins `fg:<statusKey>`, but that runs LATER: on the
844
+ // CLEAR_STATUS_ON_COMPLETION path the message is DELETED here, so by turn
845
+ // end the claim named a dead id and the unpin was a guaranteed-4xx API
846
+ // call every single turn. Worse, the durable status-pins.json row pointed
847
+ // at a deleted message for that whole window, so a crash there handed the
848
+ // next boot's sweep a row it would also fail to unpin — burning an
849
+ // attempt off the BOOT_UNPIN_MAX_ATTEMPTS forfeit ladder for nothing.
850
+ //
851
+ // AWAITED so the unpin (and its durable-row clear) is ordered strictly
852
+ // before the delete. turn-end stays the idempotent backstop: it no-ops
853
+ // once nothing is claimed for the key.
854
+ await reconcileStatusPin(`fg:${statusKey(chat, thread)}`, chat, { pinned: false })
841
855
  if (CLEAR_STATUS_ON_COMPLETION) {
842
856
  try {
843
857
  await robustApiCall(