switchroom 0.18.11 → 0.18.12

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 (122) hide show
  1. package/dist/agent-scheduler/index.js +29 -5
  2. package/dist/auth-broker/index.js +53 -13
  3. package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
  4. package/dist/cli/notion-write-pretool.mjs +29 -5
  5. package/dist/cli/switchroom.js +2453 -1293
  6. package/dist/cli/ui/index.html +163 -17
  7. package/dist/host-control/main.js +504 -100
  8. package/dist/vault/approvals/kernel-server.js +53 -13
  9. package/dist/vault/broker/server.js +162 -114
  10. package/package.json +3 -4
  11. package/profiles/_base/start.sh.hbs +65 -0
  12. package/profiles/_shared/vault-protocol.md.hbs +3 -1
  13. package/profiles/coding/CLAUDE.md.hbs +1 -1
  14. package/profiles/default/CLAUDE.md.hbs +2 -2
  15. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  16. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  17. package/telegram-plugin/bridge/bridge.ts +37 -0
  18. package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
  19. package/telegram-plugin/dist/bridge/bridge.js +73 -1
  20. package/telegram-plugin/dist/gateway/gateway.js +3602 -1007
  21. package/telegram-plugin/dist/server.js +74 -2
  22. package/telegram-plugin/flood-circuit-breaker.ts +493 -21
  23. package/telegram-plugin/gateway/approval-hold.ts +583 -0
  24. package/telegram-plugin/gateway/auth-command.ts +92 -2
  25. package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
  26. package/telegram-plugin/gateway/boot-card.ts +12 -5
  27. package/telegram-plugin/gateway/callback-query-handlers.ts +76 -1
  28. package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
  29. package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
  30. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
  31. package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
  32. package/telegram-plugin/gateway/gateway.ts +1482 -165
  33. package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
  34. package/telegram-plugin/gateway/idle-clear.ts +90 -6
  35. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
  36. package/telegram-plugin/gateway/inject-handler.ts +8 -0
  37. package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
  38. package/telegram-plugin/gateway/ipc-server.ts +43 -0
  39. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
  40. package/telegram-plugin/gateway/model-command.ts +9 -3
  41. package/telegram-plugin/gateway/pending-session-command.ts +13 -1
  42. package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
  43. package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
  44. package/telegram-plugin/gateway/queued-card-store.ts +217 -0
  45. package/telegram-plugin/gateway/session-model-file.ts +26 -1
  46. package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
  47. package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
  48. package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
  49. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
  50. package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
  51. package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
  52. package/telegram-plugin/hooks/hooks.json +9 -0
  53. package/telegram-plugin/inline-keyboard-callbacks.ts +209 -2
  54. package/telegram-plugin/operator-events.ts +23 -0
  55. package/telegram-plugin/package.json +0 -1
  56. package/telegram-plugin/permission-rule.ts +1 -0
  57. package/telegram-plugin/permission-title.ts +1 -0
  58. package/telegram-plugin/retry-api-call.ts +212 -2
  59. package/telegram-plugin/send-gate-degraded.test.ts +443 -0
  60. package/telegram-plugin/send-gate-observability.test.ts +470 -0
  61. package/telegram-plugin/send-gate-observability.ts +355 -0
  62. package/telegram-plugin/send-gate.test.ts +698 -0
  63. package/telegram-plugin/send-gate.ts +982 -0
  64. package/telegram-plugin/shared/bot-runtime.ts +17 -5
  65. package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
  66. package/telegram-plugin/status-pin-driver.ts +52 -7
  67. package/telegram-plugin/status-pin.ts +81 -0
  68. package/telegram-plugin/subagent-watcher.ts +102 -2
  69. package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
  70. package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
  71. package/telegram-plugin/tests/approval-hold-outcome.test.ts +296 -0
  72. package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
  73. package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
  74. package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
  75. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
  76. package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
  77. package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
  78. package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
  79. package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
  80. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  81. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
  82. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
  83. package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
  84. package/telegram-plugin/tests/idle-clear.test.ts +233 -3
  85. package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
  86. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +284 -0
  87. package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
  88. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
  89. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
  90. package/telegram-plugin/tests/model-command.test.ts +14 -0
  91. package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
  92. package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
  93. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
  94. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
  95. package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
  96. package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
  97. package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
  98. package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
  99. package/telegram-plugin/tests/session-model-file.test.ts +50 -0
  100. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
  101. package/telegram-plugin/tests/status-pin.test.ts +275 -1
  102. package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
  103. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
  104. package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
  105. package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
  106. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
  107. package/telegram-plugin/typing-emitter.ts +224 -0
  108. package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
  109. package/telegram-plugin/welcome-text.ts +42 -0
  110. package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
  111. package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
  112. package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
  113. package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
  114. package/vendor/hindsight-memory/scripts/session_end.py +14 -3
  115. package/vendor/hindsight-memory/scripts/session_start.py +21 -0
  116. package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
  117. package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
  118. package/vendor/hindsight-memory/tests/test_pending.py +44 -0
  119. package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
  120. package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
  121. package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
  122. package/telegram-plugin/channel-envelope-safety.ts +0 -56
@@ -29,8 +29,9 @@ import { createHash } from 'crypto'
29
29
  import { AsyncLocalStorage } from 'async_hooks'
30
30
  import { clearStaleTelegramPollingState } from '../startup-reset.js'
31
31
  import { createRetryApiCall } from '../retry-api-call.js'
32
- import { makeFloodWaitRecorder } from '../flood-circuit-breaker.js'
32
+ import { makeFloodWaitRecorder, makeFloodWaitProbe } from '../flood-circuit-breaker.js'
33
33
  import { RICH_MESSAGE_MAX_CHARS } from '../format.js'
34
+ import { shouldEmitTgPost } from './gw-trace-gate.js'
34
35
 
35
36
  // ─── tg-post tag plumbing ─────────────────────────────────────────────────
36
37
 
@@ -115,9 +116,14 @@ export function installTgPostLogger(bot: Bot): void {
115
116
  const tagSuffix = formatTgPostTags(_getTgPostTags())
116
117
  try {
117
118
  const res = await prev(method, payload, signal)
118
- process.stderr.write(
119
- `tg-post method=${method} chat=${chat} thread=${thread} parse_mode=${parseMode} bytes=${bytes} hash=${hash} status=ok err=- code=- desc=-${tagSuffix}\n`,
120
- )
119
+ // #3025: suppress zero-signal per-poll heartbeats (getUpdates/getMe
120
+ // status=ok, one line per ~30s long-poll tick) unless the operator
121
+ // set SWITCHROOM_GW_TRACE. Errors and all other methods still log.
122
+ if (shouldEmitTgPost(method, 'ok')) {
123
+ process.stderr.write(
124
+ `tg-post method=${method} chat=${chat} thread=${thread} parse_mode=${parseMode} bytes=${bytes} hash=${hash} status=ok err=- code=- desc=-${tagSuffix}\n`,
125
+ )
126
+ }
121
127
  return res
122
128
  } catch (err) {
123
129
  const errClass = err instanceof GrammyError
@@ -156,8 +162,14 @@ export function createRobustApiCall(opts: { floodStatePath?: string } = {}) {
156
162
  // #2923: persist every observed 429 flood-wait window so a restart during
157
163
  // the ban can suppress non-essential sends (boot cards) instead of feeding
158
164
  // the per-bot flood counter and prolonging the ban.
165
+ // #3084: and refuse to issue a call while that window is still open —
166
+ // otherwise the card heartbeats re-drive a request into the ban every
167
+ // few seconds once the policy stops sleeping long waits.
159
168
  ...(opts.floodStatePath
160
- ? { onFloodWait: makeFloodWaitRecorder(opts.floodStatePath) }
169
+ ? {
170
+ onFloodWait: makeFloodWaitRecorder(opts.floodStatePath),
171
+ floodWaitRemainingMs: makeFloodWaitProbe(opts.floodStatePath),
172
+ }
161
173
  : {}),
162
174
  })
163
175
  }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Gateway trace verbosity gate (#3025).
3
+ *
4
+ * The gateway emits two families of high-frequency stderr trace lines that
5
+ * are pure per-poll noise on a healthy, idle agent:
6
+ *
7
+ * - `tg-post method=getUpdates ... status=ok` / `method=getMe ... status=ok`
8
+ * — one line per long-poll tick (~every 30s) from the tg-post
9
+ * observability transformer in `bot-runtime.ts`.
10
+ * - `gw-trace shadow event=tick effects=[] global=bridge_alive_idle ...`
11
+ * — one line per delivery-machine TTL tick (~every 30s) from
12
+ * `inbound-delivery-machine-shadow.ts`.
13
+ *
14
+ * With no log rotation these ungated lines grew gateway-supervisor.log to
15
+ * 555MB on clerk / 162MB on klanker and filled the host disk. They carry
16
+ * zero signal when nothing happened: a successful empty long-poll and an
17
+ * idle no-op tick.
18
+ *
19
+ * This module centralises the "should I emit a zero-signal trace line?"
20
+ * decision behind a single debug env flag so operators can turn the full
21
+ * firehose back on when actually debugging the delivery/poll path.
22
+ *
23
+ * Flag: set `SWITCHROOM_GW_TRACE=1` (or `true`/`yes`/`on`, any case) to
24
+ * restore every trace line unconditionally. Unset / any other value → zero-signal polling
25
+ * success + idle-tick lines are suppressed, while ALL error lines, all
26
+ * non-poll methods (sendMessage, editMessageText, ...), and every tick or
27
+ * event that produced a real effect or state change are still emitted.
28
+ *
29
+ * Read once at module init — the flag is a boot-time debug switch, not a
30
+ * hot-reloadable knob, so a cached read keeps the per-line cost at a
31
+ * single boolean check.
32
+ */
33
+
34
+ /**
35
+ * Pure env-flag parse — exported so tests can exercise the parsing
36
+ * directly. Kept separate from the module-init read because the dual
37
+ * vitest/bun runner boundary means module-cache tricks
38
+ * (`vi.resetModules()` + re-import) don't work under bun's vitest shim;
39
+ * flag variants must be testable via explicit arguments.
40
+ */
41
+ export function computeGwTraceVerbose(flag: string | undefined): boolean {
42
+ const v = (flag ?? '').trim().toLowerCase()
43
+ return v === '1' || v === 'true' || v === 'yes' || v === 'on'
44
+ }
45
+
46
+ /** True when the operator opted into the full gateway trace firehose. */
47
+ export const gwTraceVerbose: boolean = computeGwTraceVerbose(
48
+ process.env.SWITCHROOM_GW_TRACE,
49
+ )
50
+
51
+ /**
52
+ * Bot API methods whose successful long-poll ticks are zero-signal:
53
+ * `getUpdates` is the long-poll itself and `getMe` is the periodic
54
+ * identity refresh. A `status=ok` on either says only "polling is alive",
55
+ * which the gateway already reports once at startup. Errors on these
56
+ * methods are NOT suppressed (see `shouldEmitTgPost`).
57
+ */
58
+ const ZERO_SIGNAL_POLL_METHODS = new Set(['getUpdates', 'getMe'])
59
+
60
+ /**
61
+ * Decide whether a `tg-post` line should be written.
62
+ *
63
+ * Suppressed (unless `SWITCHROOM_GW_TRACE` is set):
64
+ * - `status=ok` for `getUpdates` / `getMe` — the per-poll heartbeat.
65
+ *
66
+ * Always emitted:
67
+ * - any `status=err` (real failures — the whole point of the log),
68
+ * - every other method's `ok` line (sendMessage/editMessageText/... are
69
+ * genuine outbound observability, #656/#657).
70
+ */
71
+ export function shouldEmitTgPost(
72
+ method: string,
73
+ status: 'ok' | 'err',
74
+ verbose: boolean = gwTraceVerbose,
75
+ ): boolean {
76
+ if (verbose) return true
77
+ if (status === 'err') return true
78
+ return !ZERO_SIGNAL_POLL_METHODS.has(method)
79
+ }
80
+
81
+ /**
82
+ * Decide whether a `gw-trace shadow` line should be written.
83
+ *
84
+ * Suppressed (unless `SWITCHROOM_GW_TRACE` is set):
85
+ * - `event=tick` that produced NO effects AND left the machine in an
86
+ * idle global state (`bridge_alive_idle` / `bridge_dead`) — the
87
+ * no-op TTL heartbeat.
88
+ *
89
+ * Always emitted:
90
+ * - any non-tick event (real inbound/turn/bridge activity),
91
+ * - any tick that produced effects (e.g. a TTL turn expiry) or that
92
+ * landed the machine in `bridge_alive_in_turn`.
93
+ */
94
+ export function shouldEmitShadowTrace(
95
+ eventKind: string,
96
+ effectCount: number,
97
+ globalKind: string,
98
+ verbose: boolean = gwTraceVerbose,
99
+ ): boolean {
100
+ if (verbose) return true
101
+ if (eventKind !== 'tick') return true
102
+ if (effectCount > 0) return true
103
+ const idle = globalKind === 'bridge_alive_idle' || globalKind === 'bridge_dead'
104
+ return !idle
105
+ }
@@ -29,8 +29,8 @@
29
29
  * job spec.
30
30
  */
31
31
 
32
- import type { PinState, DesiredPin } from './status-pin.js'
33
- import { decidePinAction } from './status-pin.js'
32
+ import type { PinState, DesiredPin, PinRightsCache } from './status-pin.js'
33
+ import { decidePinAction, isPinRightsError } from './status-pin.js'
34
34
 
35
35
  /** Minimal subset of grammy's `bot.api` the pin driver depends on.
36
36
  * Lets tests swap in a fake without dragging in the full Bot type. */
@@ -55,6 +55,16 @@ export interface ReconcilePinArgs {
55
55
  desired: DesiredPin
56
56
  /** Optional API-failure observer. Default: silent. */
57
57
  onError?: (phase: 'pin' | 'unpin', err: unknown) => void
58
+ /** Optional per-process rights-aware negative cache (issue #3024). When a
59
+ * pin attempt fails with the permanent "not enough rights" 400, the chat is
60
+ * recorded and all subsequent pin attempts in it are skipped (no API call,
61
+ * no log). Omit to disable the cache entirely (the pre-#3024 behaviour). */
62
+ rightsCache?: PinRightsCache
63
+ /** Called EXACTLY ONCE per chat, the first time that chat is recorded as
64
+ * pin-incapable. Lets the caller emit a single warn line instead of the
65
+ * per-attempt `status-pin pin failed` spam. Only fires when `rightsCache`
66
+ * is supplied. */
67
+ onPinRightsDisabled?: (chatId: string) => void
58
68
  }
59
69
 
60
70
  /**
@@ -77,10 +87,26 @@ export async function reconcilePin(
77
87
  if (action.kind === 'noop') return args.prevState
78
88
 
79
89
  if (action.kind === 'unpin') {
80
- try {
81
- await args.api.unpinChatMessage(args.chatId, action.messageId)
82
- } catch (err) {
83
- args.onError?.('unpin', err)
90
+ // Skip the unpin API call in a chat the bot can't manage pins in — the
91
+ // call would fail with the same rights 400 and spam the log. The claim is
92
+ // dropped either way (below), so skipping is safe.
93
+ if (!args.rightsCache?.isBlocked(args.chatId)) {
94
+ try {
95
+ await args.api.unpinChatMessage(args.chatId, action.messageId)
96
+ } catch (err) {
97
+ // Symmetric with the pin path below: a permanent rights 400 on UNPIN
98
+ // (rights revoked mid-session after we pinned) also enters the
99
+ // negative cache and logs once via onPinRightsDisabled — otherwise
100
+ // every later unpin attempt would burn an API call and spam
101
+ // `status-pin unpin failed` per attempt, the exact class this cache
102
+ // exists to kill (#3073 review finding). Claim is dropped regardless.
103
+ if (args.rightsCache && isPinRightsError(err)) {
104
+ const firstTime = args.rightsCache.block(args.chatId)
105
+ if (firstTime) args.onPinRightsDisabled?.(args.chatId)
106
+ } else {
107
+ args.onError?.('unpin', err)
108
+ }
109
+ }
84
110
  }
85
111
  // Drop the claim regardless of the unpin outcome. A stuck claim would
86
112
  // leave a permanent pin on a crash / out-of-band unpin — the exact
@@ -89,14 +115,33 @@ export async function reconcilePin(
89
115
  }
90
116
 
91
117
  // action.kind === 'pin' — pin an EXISTING message, silently.
118
+ // Rights-aware negative cache (issue #3024): if a prior attempt in this chat
119
+ // already failed with the permanent "not enough rights" 400, skip silently —
120
+ // no API call, no log. Don't claim the message (the pin never happened), so a
121
+ // later reconcile after a restart (cache cleared) can retry.
122
+ if (args.rightsCache?.isBlocked(args.chatId)) {
123
+ return args.prevState
124
+ }
92
125
  try {
93
126
  await args.api.pinChatMessage(args.chatId, action.messageId, {
94
127
  disable_notification: true,
95
128
  })
96
129
  } catch (err) {
97
- args.onError?.('pin', err)
130
+ // A permanent pin-rights failure enters the negative cache and logs ONCE
131
+ // (via onPinRightsDisabled) — every subsequent attempt in this chat is then
132
+ // skipped above. Transient failures (429 / 5xx / network) are NOT cached
133
+ // and route through onError as before, preserving retry behaviour.
134
+ if (args.rightsCache && isPinRightsError(err)) {
135
+ const firstTime = args.rightsCache.block(args.chatId)
136
+ if (firstTime) args.onPinRightsDisabled?.(args.chatId)
137
+ } else {
138
+ args.onError?.('pin', err)
139
+ }
98
140
  // Don't claim a message we failed to pin — the next reconcile retries.
99
141
  return args.prevState
100
142
  }
143
+ // Pin succeeded — if this chat was previously cached as pin-incapable, rights
144
+ // were granted since; forget it so we resume normal behaviour immediately.
145
+ args.rightsCache?.clear(args.chatId)
101
146
  return { messageId: action.messageId }
102
147
  }
@@ -74,3 +74,84 @@ export function decidePinAction(
74
74
  // re-pins the new one on the next reconcile.
75
75
  return { kind: 'unpin', messageId: prev.messageId }
76
76
  }
77
+
78
+ /** Extract a lowercased human description from any thrown value, preferring
79
+ * grammy's structured `.description` (the wire text Telegram returned) and
80
+ * falling back to `.message` / String(). Kept dependency-free — matches on
81
+ * the wire text, never on a grammy class import. */
82
+ function errorDescription(err: unknown): string {
83
+ if (err != null && typeof err === 'object') {
84
+ const o = err as { description?: unknown; message?: unknown }
85
+ if (typeof o.description === 'string' && o.description.length > 0) {
86
+ return o.description.toLowerCase()
87
+ }
88
+ if (typeof o.message === 'string' && o.message.length > 0) {
89
+ return o.message.toLowerCase()
90
+ }
91
+ }
92
+ return String(err).toLowerCase()
93
+ }
94
+
95
+ /**
96
+ * True when a pin/unpin failure is the PERMANENT "the bot lacks pin rights in
97
+ * this chat" class — Telegram returns this as a 400 (not a 403):
98
+ * "not enough rights to manage pinned messages in the chat". This is the one
99
+ * error class that will NOT self-heal on retry: the bot is simply not an admin
100
+ * (or lacks the "Pin messages" right) in that supergroup, and every subsequent
101
+ * pin attempt in the same chat fails identically until an admin grants the
102
+ * right (which only takes effect after a gateway restart re-reads chat perms).
103
+ *
104
+ * Transient classes (429 flood-wait, 5xx, network HttpError) are deliberately
105
+ * EXCLUDED — they must keep the existing retry behaviour, never enter the
106
+ * negative cache. Only this permanent-rights class is cacheable.
107
+ *
108
+ * The single source of truth for the pin-rights concept: the gateway's
109
+ * `unhandled-rejection-policy.ts` and the various edit-error classifiers each
110
+ * fold `not enough rights` into a broader boolean for their own purpose; this
111
+ * is the one helper dedicated to the pin negative-cache decision.
112
+ */
113
+ export function isPinRightsError(err: unknown): boolean {
114
+ return errorDescription(err).includes('not enough rights')
115
+ }
116
+
117
+ /**
118
+ * Per-process, per-chat negative cache for chats the bot cannot pin in.
119
+ *
120
+ * When an auto status-pin attempt fails with the permanent pin-rights 400
121
+ * (`isPinRightsError`), the chat is recorded here as pin-incapable so the
122
+ * driver skips every subsequent auto-pin attempt in that chat — no wasted API
123
+ * call, no repeated `status-pin pin failed` log line (issue #3024: marko logged
124
+ * 41 identical pin-rights rejections in 48h, one per attempt).
125
+ *
126
+ * Deliberately IN-MEMORY / per-boot only: pin rights may be granted later, and
127
+ * Telegram surfaces the new permission to the bot only on a fresh chat-member
128
+ * fetch — a gateway restart. Clearing the cache on restart is therefore the
129
+ * correct re-enable trigger. An explicit `pin_message` tool success in a chat
130
+ * also clears its entry (rights were granted mid-session).
131
+ *
132
+ * Scope: the AUTO status-pin path only. The explicit `pin_message` MCP tool
133
+ * never consults this cache — it always attempts and surfaces the error to the
134
+ * agent as a normal tool error.
135
+ */
136
+ export class PinRightsCache {
137
+ private readonly blocked = new Set<string>()
138
+
139
+ /** True when auto-pin should be skipped for this chat (already known bad). */
140
+ isBlocked(chatId: string): boolean {
141
+ return this.blocked.has(chatId)
142
+ }
143
+
144
+ /** Record a chat as pin-incapable. Returns `true` only the FIRST time a chat
145
+ * is added, so the caller can log the warn line exactly once per chat. */
146
+ block(chatId: string): boolean {
147
+ if (this.blocked.has(chatId)) return false
148
+ this.blocked.add(chatId)
149
+ return true
150
+ }
151
+
152
+ /** Forget a chat (rights re-granted, e.g. an explicit pin later succeeded).
153
+ * Returns `true` if an entry was actually removed. */
154
+ clear(chatId: string): boolean {
155
+ return this.blocked.delete(chatId)
156
+ }
157
+ }
@@ -155,6 +155,25 @@ export interface WorkerEntry {
155
155
  * every real long-runner always carries a `toolu_…` id.
156
156
  */
157
157
  inflightToolUseIds: Set<string>
158
+ /**
159
+ * Wall-clock ms of the most recent EMITTED "terminal synthesis deferred"
160
+ * log line for this entry, or null if the entry is not currently in the
161
+ * deferred state (never entered it, or resumed out of it). Drives the
162
+ * per-worker log rate-limit (#3092): null ⇒ this is a fresh entry into the
163
+ * deferred state and MUST log; non-null ⇒ log only once
164
+ * `deferralLogIntervalMs` has elapsed. Reset to null on the un-stall path
165
+ * so a stall → resume → stall cycle logs its first deferral again.
166
+ */
167
+ deferralLoggedAt: number | null
168
+ /**
169
+ * Count of deferral ticks whose log line was SUPPRESSED by the rate-limit
170
+ * since this entry entered the deferred state (#3092). Carried into the
171
+ * next emitted line (and into the resume / cap-crossing transition lines)
172
+ * so the suppression is itself visible and the operator can see the true
173
+ * tick volume without reading 1,547 lines. Reset alongside
174
+ * `deferralLoggedAt`.
175
+ */
176
+ deferralSuppressedTicks: number
158
177
  /**
159
178
  * True if the underlying JSONL file existed before the watcher started.
160
179
  * Historical entries are tracked for late state transitions but are
@@ -330,6 +349,22 @@ export interface SubagentWatcherConfig {
330
349
  * `SWITCHROOM_SUBAGENT_INFLIGHT_TERMINAL_CAP_MS`.
331
350
  */
332
351
  inflightTerminalCapMs?: number
352
+ /**
353
+ * Rate-limit (ms) on the repeated "terminal synthesis deferred" log line
354
+ * for a single worker (#3092). The deferral decision is re-evaluated on
355
+ * every ~1s rescan tick; without this gate each re-evaluation logged,
356
+ * emitting ~1 line/sec for as long as the worker stayed blocked (1,547
357
+ * lines in 35 min on the live overlord gateway, drowning the concurrent
358
+ * 429 flood-ban lines of #3084).
359
+ *
360
+ * Semantics: the FIRST tick that enters the deferred state always logs;
361
+ * subsequent ticks log only once this many ms have passed since the last
362
+ * emitted line. State CHANGES are never suppressed — the cap-crossing
363
+ * ("proceeding with terminal synthesis") and the resume/un-stall lines
364
+ * fire regardless. Default 60_000 (1 min). Env override
365
+ * `SWITCHROOM_SUBAGENT_DEFERRAL_LOG_INTERVAL_MS`.
366
+ */
367
+ deferralLogIntervalMs?: number
333
368
  /**
334
369
  * Freshness window (ms) for promoting a running-at-boot worker file to
335
370
  * live. A file whose last write (mtime) is older than this is treated as
@@ -600,6 +635,16 @@ const DEFAULT_SILENT_STALL_TERMINAL_MS = 300_000
600
635
  // reaper TTL, so the watcher — not the reaper — still owns the terminal
601
636
  // transition for a worker that died mid-tool.
602
637
  const DEFAULT_INFLIGHT_TERMINAL_CAP_MS = 45 * 60_000
638
+ // Minimum wall-clock gap between two "terminal synthesis deferred" log lines
639
+ // for the SAME worker (#3092). `checkStalls` runs on the ~1s rescan tick, and
640
+ // the deferral is re-evaluated (and, before this gate, re-logged) on every one
641
+ // of them. A single worker legitimately blocked inside a long tool call
642
+ // produced 1,547 near-identical lines in 35 minutes on the overlord gateway,
643
+ // burying the concurrent Telegram 429 flood-ban lines (#3084). The DECISION is
644
+ // re-made every tick — only the LOGGING is rate-limited: first entry into the
645
+ // deferred state logs immediately, then at most once per this interval, and
646
+ // every state CHANGE (resume, cap-crossing) logs unconditionally.
647
+ const DEFAULT_DEFERRAL_LOG_INTERVAL_MS = 60_000
603
648
 
604
649
  /**
605
650
  * Tools that legitimately run for minutes with ZERO intervening JSONL
@@ -1183,6 +1228,18 @@ export function readSubTail(
1183
1228
  log?.(`subagent-watcher: onUnstall callback error ${entry.agentId}: ${(cbErr as Error).message}`)
1184
1229
  }
1185
1230
  }
1231
+ // STATE CHANGE out of the deferred state (#3092) — never rate-
1232
+ // limited. A worker that was deferring terminal synthesis behind an
1233
+ // in-flight tool call has RESURRECTED; that transition is the
1234
+ // payoff of the deferral and must be visible even though the
1235
+ // intervening per-tick deferral lines were suppressed. Report the
1236
+ // suppressed volume so the quiet window is accounted for, then
1237
+ // re-arm so a subsequent re-stall logs its first deferral again.
1238
+ if (entry.deferralLoggedAt != null) {
1239
+ log?.(`subagent-watcher: in-flight deferral resolved for ${entry.agentId} (worker resumed after ${idleSecBeforeBump}s idle — deferral was correct, not a dead worker; ${entry.deferralSuppressedTicks} deferral tick(s) suppressed since the last deferral line)`)
1240
+ entry.deferralLoggedAt = null
1241
+ entry.deferralSuppressedTicks = 0
1242
+ }
1186
1243
  log?.(`subagent-watcher: stall cleared for ${entry.agentId} (activity resumed after ${idleSecBeforeBump}s — re-arming detection)`)
1187
1244
  }
1188
1245
  if (ev.kind === 'sub_agent_model') {
@@ -1457,6 +1514,10 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1457
1514
  config.inflightTerminalCapMs
1458
1515
  ?? parseEnvMs('SWITCHROOM_SUBAGENT_INFLIGHT_TERMINAL_CAP_MS')
1459
1516
  ?? DEFAULT_INFLIGHT_TERMINAL_CAP_MS
1517
+ const deferralLogIntervalMs =
1518
+ config.deferralLogIntervalMs
1519
+ ?? parseEnvMs('SWITCHROOM_SUBAGENT_DEFERRAL_LOG_INTERVAL_MS')
1520
+ ?? DEFAULT_DEFERRAL_LOG_INTERVAL_MS
1460
1521
  const inflightPromoteMaxAgeMs =
1461
1522
  config.inflightPromoteMaxAgeMs
1462
1523
  ?? parseEnvMs('SWITCHROOM_SUBAGENT_INFLIGHT_MAX_AGE_MS')
@@ -1603,6 +1664,8 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1603
1664
  stalledAt: null,
1604
1665
  completionNotified: false,
1605
1666
  stallTerminalSynthesised: false,
1667
+ deferralLoggedAt: null,
1668
+ deferralSuppressedTicks: 0,
1606
1669
  lastSummaryLine: '',
1607
1670
  lastResultText: '',
1608
1671
  lastProgressBucketIdx: null,
@@ -1987,6 +2050,19 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1987
2050
  existing.stallNotified = false
1988
2051
  existing.stalledAt = null
1989
2052
  existing.stallTerminalSynthesised = false
2053
+ // Re-arm the deferral log gate alongside its stall siblings (#3092).
2054
+ // Unreachable with stale state TODAY (both routes out of the deferred
2055
+ // state to `done` — the cap-crossing and the un-stall drain — already
2056
+ // null `deferralLoggedAt`), so this is belt-and-braces, not a live fix.
2057
+ // Reset it here anyway: a resurrected entry must be indistinguishable
2058
+ // from a fresh one, so a genuinely NEW stall always logs its FIRST
2059
+ // deferral line immediately. Leaving these to a reachability argument
2060
+ // makes the gate fragile by construction — a later reorder of the drain,
2061
+ // or a third path to `done`, would silently suppress that first line for
2062
+ // up to `deferralLogIntervalMs`, losing the exact signal this gate
2063
+ // exists to preserve.
2064
+ existing.deferralLoggedAt = null
2065
+ existing.deferralSuppressedTicks = 0
1990
2066
  // Re-arm the completion notification INTENTIONALLY (issue #3023). The
1991
2067
  // false synthesized finish already fired `onFinish` once, delivering a
1992
2068
  // (possibly wrong / incomplete) synthesized handback to the parent.
@@ -2212,10 +2288,34 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
2212
2288
  if (entry.inflightToolUseIds.size > 0) {
2213
2289
  const totalIdleMs = n - entry.lastActivityAt
2214
2290
  if (totalIdleMs < inflightTerminalCapMs) {
2215
- log?.(`subagent-watcher: silent-stall terminal synthesis deferred for ${entry.agentId} — ${entry.inflightToolUseIds.size} tool call(s) still in flight, ${Math.floor(totalIdleMs / 1000)}s idle < ${Math.floor(inflightTerminalCapMs / 1000)}s cap (long-running tool, not a dead worker)`)
2291
+ // Log-rate gate (#3092). The deferral DECISION above is re-made on
2292
+ // every ~1s rescan tick and is correct; only its LOGGING is gated.
2293
+ // Emit on first entry into the deferred state, then at most once per
2294
+ // `deferralLogIntervalMs`. Everything else is counted, not printed —
2295
+ // the suppressed count rides the next emitted line so no volume is
2296
+ // lost, just the 1/sec repetition (1,547 lines / 35 min on overlord,
2297
+ // burying the concurrent #3084 flood-ban lines).
2298
+ const lastLoggedAt = entry.deferralLoggedAt
2299
+ const firstEntry = lastLoggedAt == null
2300
+ if (firstEntry || n - lastLoggedAt >= deferralLogIntervalMs) {
2301
+ const suppressed = entry.deferralSuppressedTicks
2302
+ const suffix = firstEntry
2303
+ ? ` — further deferral ticks for this worker are rate-limited to 1 line / ${Math.floor(deferralLogIntervalMs / 1000)}s until it resumes or crosses the cap`
2304
+ : ` — still deferred (${suppressed} tick(s) suppressed since the last line)`
2305
+ log?.(`subagent-watcher: silent-stall terminal synthesis deferred for ${entry.agentId} — ${entry.inflightToolUseIds.size} tool call(s) still in flight, ${Math.floor(totalIdleMs / 1000)}s idle < ${Math.floor(inflightTerminalCapMs / 1000)}s cap (long-running tool, not a dead worker)${suffix}`)
2306
+ entry.deferralLoggedAt = n
2307
+ entry.deferralSuppressedTicks = 0
2308
+ } else {
2309
+ entry.deferralSuppressedTicks++
2310
+ }
2216
2311
  continue
2217
2312
  }
2218
- log?.(`subagent-watcher: in-flight deferral cap reached for ${entry.agentId} (${Math.floor(totalIdleMs / 1000)}s idle >= ${Math.floor(inflightTerminalCapMs / 1000)}s cap with ${entry.inflightToolUseIds.size} tool call(s) still unresolved) — treating as died-mid-tool, proceeding with terminal synthesis`)
2313
+ // STATE CHANGE — never rate-limited. The deferral ends here and a
2314
+ // terminal synthesis fires; this is the transition an operator needs.
2315
+ const suppressedTotal = entry.deferralSuppressedTicks
2316
+ log?.(`subagent-watcher: in-flight deferral cap reached for ${entry.agentId} (${Math.floor(totalIdleMs / 1000)}s idle >= ${Math.floor(inflightTerminalCapMs / 1000)}s cap with ${entry.inflightToolUseIds.size} tool call(s) still unresolved) — treating as died-mid-tool, proceeding with terminal synthesis (${suppressedTotal} deferral tick(s) suppressed since the last deferral line)`)
2317
+ entry.deferralLoggedAt = null
2318
+ entry.deferralSuppressedTicks = 0
2219
2319
  }
2220
2320
  // TODO(#3023/PR #3029): the cap-reached path above is a SECOND source of
2221
2321
  // possibly-false terminal synthesis (a worker mid-very-long-tool that is
@@ -66,14 +66,27 @@ describe('activity-card durability wiring', () => {
66
66
  })
67
67
 
68
68
  it('the boot reaper runs ONLY after the startup mutex is won (never at import time)', () => {
69
- // Both invocation sites (mutex-won + mutex-fallback) sit AFTER
70
- // acquireStartupLock, alongside statusPinBootCleanup.
69
+ // #3026: the reaper is now invoked via the mutex-gated orchestrator
70
+ // runBootPinCleanupAndDmSweep() (which awaits it alongside
71
+ // statusPinBootCleanup + queuedCardBootReaper, then runs the DM
72
+ // stale-pin sweep). Invariant unchanged: reachable only post-lock.
73
+ // (1) The orchestrator awaits the reaper.
74
+ const orchestrator = between(
75
+ gatewaySrc,
76
+ 'async function runBootPinCleanupAndDmSweep()',
77
+ 'dmPinSweepEligible = true',
78
+ )
79
+ expect(orchestrator).toMatch(/await activityCardBootReaper\(\)/)
80
+ // (2) Both orchestrator invocation sites (mutex-won + mutex-fallback)
81
+ // sit AFTER acquireStartupLock.
71
82
  const afterLock = between(gatewaySrc, 'await acquireStartupLock({', 'catch (err)')
72
- expect(afterLock).toMatch(/void activityCardBootReaper\(\)/)
73
- // And it must NOT be invoked at module import time — the same losing-double-
74
- // boot hazard the NOTE at statusPinBootCleanup documents.
83
+ expect(afterLock).toMatch(/void runBootPinCleanupAndDmSweep\(\)/)
84
+ // (3) Neither the reaper nor the orchestrator is invoked at module import
85
+ // time — the same losing-double-boot hazard the NOTE at
86
+ // statusPinBootCleanup documents.
75
87
  const beforeMain = gatewaySrc.split('await acquireStartupLock({')[0] ?? ''
76
88
  expect(beforeMain).not.toMatch(/^\s*void activityCardBootReaper\(\)/m)
89
+ expect(beforeMain).not.toMatch(/^\s*void runBootPinCleanupAndDmSweep\(\)/m)
77
90
  })
78
91
 
79
92
  it('the reaper wrapper counts a benign-400 as vanished, not finalized (honest boot log)', () => {