switchroom 0.15.2 → 0.15.4
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.
- package/bin/turn-pacing-hook.sh +112 -0
- package/bin/workspace-dynamic-hook.sh +105 -15
- package/bin/workspace-stable-hook.sh +2 -2
- package/dist/agent-scheduler/index.js +2 -1
- package/dist/auth-broker/index.js +75 -12
- package/dist/cli/notion-write-pretool.mjs +2 -1
- package/dist/cli/switchroom.js +1596 -1515
- package/dist/host-control/main.js +2 -1
- package/dist/vault/approvals/kernel-server.js +2 -1
- package/dist/vault/broker/server.js +2 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +35 -2
- package/profiles/default/CLAUDE.md.hbs +13 -4
- package/telegram-plugin/dist/gateway/gateway.js +533 -33
- package/telegram-plugin/gateway/gateway.ts +152 -14
- package/telegram-plugin/gateway/inbound-spool.ts +107 -16
- package/telegram-plugin/gateway/model-command.ts +261 -7
- package/telegram-plugin/tests/inbound-spool.test.ts +101 -0
- package/telegram-plugin/tests/model-command.test.ts +179 -0
- package/telegram-plugin/tests/welcome-text.test.ts +11 -0
- package/telegram-plugin/uat/scenarios/jtbd-model-command-dm.test.ts +93 -0
- package/telegram-plugin/welcome-text.ts +16 -1
- package/profiles/default/workspace/HEARTBEAT.md.hbs +0 -40
|
@@ -247,6 +247,7 @@ import {
|
|
|
247
247
|
import {
|
|
248
248
|
fetchQuota,
|
|
249
249
|
formatQuotaBlock,
|
|
250
|
+
formatQuotaLine,
|
|
250
251
|
type QuotaResult,
|
|
251
252
|
} from '../quota-check.js'
|
|
252
253
|
import {
|
|
@@ -258,7 +259,17 @@ import { DEFAULT_SLOT } from '../../src/auth/accounts.js'
|
|
|
258
259
|
import { currentActiveSlot, type AuthCodeOutcome } from '../../src/auth/manager.js'
|
|
259
260
|
import { injectSlashCommand as injectSlashCommandImpl } from '../../src/agents/inject.js'
|
|
260
261
|
import { handleInjectCommand } from './inject-handler.js'
|
|
261
|
-
import {
|
|
262
|
+
import {
|
|
263
|
+
parseModelCommand,
|
|
264
|
+
handleModelCommand,
|
|
265
|
+
buildModelMenu,
|
|
266
|
+
handleModelMenuCallback,
|
|
267
|
+
MODEL_CALLBACK_PREFIX,
|
|
268
|
+
type ModelMenuDeps,
|
|
269
|
+
type ModelCommandDeps,
|
|
270
|
+
type ModelMenuReply,
|
|
271
|
+
} from './model-command.js'
|
|
272
|
+
import { discoverModels, selectModel } from '../../src/agents/model-picker.js'
|
|
262
273
|
import { type BannerState } from '../slot-banner.js'
|
|
263
274
|
import { refreshBanner } from '../slot-banner-driver.js'
|
|
264
275
|
import { loadConfig as loadSwitchroomConfig, findConfigFile as findSwitchroomConfigFile } from '../../src/config/loader.js'; import { resolveAgentConfig } from '../../src/config/merge.js'
|
|
@@ -6598,7 +6609,7 @@ if (!STATIC) {
|
|
|
6598
6609
|
// promise EXPLICITLY (honest failure) instead of letting it sit
|
|
6599
6610
|
// forever. This is what makes the guarantee deterministic: every
|
|
6600
6611
|
// queued message ends either delivered or visibly retracted.
|
|
6601
|
-
inboundSpool?.sweepEscalations((e) => {
|
|
6612
|
+
inboundSpool?.sweepEscalations((e, { postNotice }) => {
|
|
6602
6613
|
const chat = e.msg.chatId
|
|
6603
6614
|
const escThread =
|
|
6604
6615
|
typeof e.msg.meta?.threadId === 'string' && e.msg.meta.threadId
|
|
@@ -6609,7 +6620,14 @@ if (!STATIC) {
|
|
|
6609
6620
|
// the message is being declared undeliverable, so the queued-status must
|
|
6610
6621
|
// not dangle beside the "couldn't deliver" notice (idempotent best-effort;
|
|
6611
6622
|
// a normal turn-start/turn-end reaps far sooner — this is the 15-min edge).
|
|
6623
|
+
// Reaping happens for EVERY dropped entry; only the user-facing notice is
|
|
6624
|
+
// coalesced (postNotice), so a burst of undeliverable inbounds doesn't
|
|
6625
|
+
// leave dangling placeholders even when its notice is suppressed.
|
|
6612
6626
|
reapQueuedStatus(chat, escThread)
|
|
6627
|
+
// Coalesced per chat by the spool's sliding window — a multi-restart
|
|
6628
|
+
// outage that re-ages a synthetic into the bound every 15 min posts ONE
|
|
6629
|
+
// notice, not one per cycle (the 2026-06-09 marko "please resend" spam).
|
|
6630
|
+
if (!postNotice) return
|
|
6613
6631
|
void swallowingApiCall(
|
|
6614
6632
|
() =>
|
|
6615
6633
|
bot.api.sendMessage(
|
|
@@ -13756,6 +13774,14 @@ function buildAgentAudit(agentName: string): AgentAudit | undefined {
|
|
|
13756
13774
|
// broker's fleet-wide `ListStateData` payload via
|
|
13757
13775
|
// `buildAuthSummaryFromBroker`, with billingType pulled from the
|
|
13758
13776
|
// agent's `.claude.json` (the broker doesn't track plan tier).
|
|
13777
|
+
/**
|
|
13778
|
+
* Live session-model override set by the `/model` picker (session-only). Held
|
|
13779
|
+
* in gateway memory so it clears on restart, the same point at which claude's
|
|
13780
|
+
* session reverts to the configured model — keeping `/status` honest without
|
|
13781
|
+
* a persisted store. Null when no session switch is active.
|
|
13782
|
+
*/
|
|
13783
|
+
let activeSessionModelOverride: string | null = null
|
|
13784
|
+
|
|
13759
13785
|
async function buildAgentMetadata(agentName: string): Promise<AgentMetadata> {
|
|
13760
13786
|
type AgentListResp = {
|
|
13761
13787
|
agents: Array<{
|
|
@@ -13784,6 +13810,7 @@ async function buildAgentMetadata(agentName: string): Promise<AgentMetadata> {
|
|
|
13784
13810
|
return {
|
|
13785
13811
|
agentName,
|
|
13786
13812
|
model: a?.model ?? null,
|
|
13813
|
+
sessionModel: activeSessionModelOverride,
|
|
13787
13814
|
extendsProfile: (a?.extends ?? a?.template) ?? null,
|
|
13788
13815
|
topicName: a?.topic_name ?? null,
|
|
13789
13816
|
topicEmoji: a?.topic_emoji ?? null,
|
|
@@ -13922,19 +13949,39 @@ bot.command('inject', async ctx => {
|
|
|
13922
13949
|
})
|
|
13923
13950
|
})
|
|
13924
13951
|
|
|
13925
|
-
// /model —
|
|
13926
|
-
//
|
|
13927
|
-
//
|
|
13928
|
-
//
|
|
13929
|
-
//
|
|
13952
|
+
// /model — model dashboard + switch for this agent's live session.
|
|
13953
|
+
// Bare form: drives claude's own /model picker (open → parse → Esc,
|
|
13954
|
+
// src/agents/model-picker.ts) to discover the live option list — no
|
|
13955
|
+
// hardcoded model names — and renders it as an inline-keyboard menu
|
|
13956
|
+
// with the current model + a brief quota line. A tap re-opens the
|
|
13957
|
+
// picker fresh and applies session-only (`s`). Kill-switch
|
|
13958
|
+
// SWITCHROOM_MODEL_MENU=0 reverts the bare form to the static v1
|
|
13959
|
+
// text. The typed argument form rides the allowlisted inject
|
|
13960
|
+
// primitive unchanged. Implementation in model-command.ts so it's
|
|
13930
13961
|
// unit-testable without booting the bot.
|
|
13931
|
-
|
|
13932
|
-
|
|
13933
|
-
|
|
13934
|
-
|
|
13935
|
-
|
|
13936
|
-
inject: injectSlashCommandImpl,
|
|
13962
|
+
function buildModelDeps(): ModelMenuDeps & ModelCommandDeps {
|
|
13963
|
+
return {
|
|
13964
|
+
discover: (a) => discoverModels(a),
|
|
13965
|
+
select: (a, label) => selectModel(a, label),
|
|
13966
|
+
isBusy: () => currentTurn !== null,
|
|
13937
13967
|
getAgentName: getMyAgentName,
|
|
13968
|
+
getQuotaBrief: async () => {
|
|
13969
|
+
// Broker-routed probe first (authoritative), local headers as
|
|
13970
|
+
// fallback — same ladder as the boot card / legacy /usage.
|
|
13971
|
+
try {
|
|
13972
|
+
const probed = await probeQuotaForBootCard(getMyAgentName(), 4000)
|
|
13973
|
+
if (probed?.ok) return formatQuotaLine(probed.data)
|
|
13974
|
+
} catch { /* fall through */ }
|
|
13975
|
+
try {
|
|
13976
|
+
const agentDir = resolveAgentDirFromEnv()
|
|
13977
|
+
if (agentDir) {
|
|
13978
|
+
const local = await fetchQuota({ claudeConfigDir: join(agentDir, '.claude') })
|
|
13979
|
+
if (local.ok) return formatQuotaLine(local.data)
|
|
13980
|
+
}
|
|
13981
|
+
} catch { /* quota is garnish — never block the menu on it */ }
|
|
13982
|
+
return null
|
|
13983
|
+
},
|
|
13984
|
+
inject: injectSlashCommandImpl,
|
|
13938
13985
|
getConfiguredModel: () => {
|
|
13939
13986
|
type AgentListResp = { agents: Array<{ name: string; model?: string | null }> }
|
|
13940
13987
|
const data = switchroomExecJson<AgentListResp>(['agent', 'list'])
|
|
@@ -13942,7 +13989,30 @@ bot.command('model', async ctx => {
|
|
|
13942
13989
|
},
|
|
13943
13990
|
escapeHtml: escapeHtmlForTg,
|
|
13944
13991
|
preBlock,
|
|
13945
|
-
}
|
|
13992
|
+
}
|
|
13993
|
+
}
|
|
13994
|
+
|
|
13995
|
+
function modelMenuReplyMarkup(reply: ModelMenuReply): InlineKeyboard | undefined {
|
|
13996
|
+
if (!reply.keyboard) return undefined
|
|
13997
|
+
const kb = new InlineKeyboard()
|
|
13998
|
+
for (const row of reply.keyboard) {
|
|
13999
|
+
for (const btn of row) kb.text(btn.text, btn.callback_data)
|
|
14000
|
+
kb.row()
|
|
14001
|
+
}
|
|
14002
|
+
return kb
|
|
14003
|
+
}
|
|
14004
|
+
|
|
14005
|
+
bot.command('model', async ctx => {
|
|
14006
|
+
if (!isAuthorizedSender(ctx)) return
|
|
14007
|
+
const text = ctx.message?.text ?? ctx.channelPost?.text ?? ''
|
|
14008
|
+
const parsed = parseModelCommand(text) ?? { kind: 'show' as const }
|
|
14009
|
+
const deps = buildModelDeps()
|
|
14010
|
+
if (parsed.kind === 'show' && process.env.SWITCHROOM_MODEL_MENU !== '0') {
|
|
14011
|
+
const menu = await buildModelMenu(deps)
|
|
14012
|
+
await switchroomReply(ctx, menu.text, { html: true, reply_markup: modelMenuReplyMarkup(menu) })
|
|
14013
|
+
return
|
|
14014
|
+
}
|
|
14015
|
+
const reply = await handleModelCommand(parsed, deps)
|
|
13946
14016
|
await switchroomReply(ctx, reply.text, { html: reply.html })
|
|
13947
14017
|
})
|
|
13948
14018
|
|
|
@@ -18330,6 +18400,74 @@ bot.on('callback_query:data', async ctx => {
|
|
|
18330
18400
|
return
|
|
18331
18401
|
}
|
|
18332
18402
|
|
|
18403
|
+
// `mdl:*` — model-menu taps (/model dashboard). `mdl:s:<tag>`
|
|
18404
|
+
// selects a model by label-tag via a fresh picker discovery (never
|
|
18405
|
+
// a stale index); `mdl:r` re-renders. Strict allowFrom gate like
|
|
18406
|
+
// every other mutating callback family — a model switch changes the
|
|
18407
|
+
// fleet's quota burn profile.
|
|
18408
|
+
if (data.startsWith(MODEL_CALLBACK_PREFIX)) {
|
|
18409
|
+
const access = loadAccess()
|
|
18410
|
+
const senderId = String(ctx.from?.id ?? '')
|
|
18411
|
+
if (!access.allowFrom.includes(senderId)) {
|
|
18412
|
+
await ctx.answerCallbackQuery({ text: 'Not authorized.' })
|
|
18413
|
+
return
|
|
18414
|
+
}
|
|
18415
|
+
// Kill-switch covers the callback family too — stale menus keep
|
|
18416
|
+
// their buttons after the flag flips, and the flag exists exactly
|
|
18417
|
+
// for "picker-driving is misbehaving" (#2263 review blocker 2).
|
|
18418
|
+
if (process.env.SWITCHROOM_MODEL_MENU === '0') {
|
|
18419
|
+
await ctx.answerCallbackQuery({ text: 'Model menu is disabled (SWITCHROOM_MODEL_MENU=0).' }).catch(() => {})
|
|
18420
|
+
await ctx
|
|
18421
|
+
.editMessageText('Model menu is disabled on this agent. Use <code>/model <name></code>.', {
|
|
18422
|
+
parse_mode: 'HTML',
|
|
18423
|
+
reply_markup: { inline_keyboard: [] },
|
|
18424
|
+
})
|
|
18425
|
+
.catch(() => {})
|
|
18426
|
+
return
|
|
18427
|
+
}
|
|
18428
|
+
const modelDeps = buildModelDeps()
|
|
18429
|
+
// Mid-turn refusal is INSTANT (a sync isBusy() check, no picker drive),
|
|
18430
|
+
// so handle it before the "Working…" ack: toast WHY and leave the menu
|
|
18431
|
+
// message untouched (buttons intact) so the operator taps again when
|
|
18432
|
+
// idle. Editing the menu into a button-less "try again" line was the
|
|
18433
|
+
// "nothing happened" report — the menu looked dead.
|
|
18434
|
+
if (modelDeps.isBusy()) {
|
|
18435
|
+
await ctx
|
|
18436
|
+
.answerCallbackQuery({ text: '⏳ Agent is mid-turn — tap again when it’s idle', show_alert: false })
|
|
18437
|
+
.catch(() => {})
|
|
18438
|
+
return
|
|
18439
|
+
}
|
|
18440
|
+
// Ack IMMEDIATELY — the select path drives the picker (multi-second);
|
|
18441
|
+
// leaving the tap spinning invites a double-tap, which queues a second
|
|
18442
|
+
// drive behind the pane lock. A callback can only be answered once, so
|
|
18443
|
+
// the rich result (what was set / why it failed) is conveyed by the
|
|
18444
|
+
// message edit — which now ALWAYS keeps the menu buttons.
|
|
18445
|
+
await ctx.answerCallbackQuery({ text: 'Switching…' }).catch(() => {})
|
|
18446
|
+
try {
|
|
18447
|
+
const outcome = await handleModelMenuCallback(data, modelDeps)
|
|
18448
|
+
// Record a successful session switch so /status reflects what's
|
|
18449
|
+
// actually running. In-memory only → clears when the gateway (and thus
|
|
18450
|
+
// claude's session) restarts, exactly matching the session-only scope.
|
|
18451
|
+
if (outcome.selectedModel) {
|
|
18452
|
+
activeSessionModelOverride = outcome.selectedModel
|
|
18453
|
+
}
|
|
18454
|
+
// toastOnly: a no-op outcome that should not disturb the menu (defence
|
|
18455
|
+
// in depth — the isBusy() short-circuit above is the live path).
|
|
18456
|
+
if (outcome.toastOnly) return
|
|
18457
|
+
await ctx
|
|
18458
|
+
.editMessageText(outcome.reply.text, {
|
|
18459
|
+
parse_mode: 'HTML',
|
|
18460
|
+
reply_markup: modelMenuReplyMarkup(outcome.reply) ?? { inline_keyboard: [] },
|
|
18461
|
+
})
|
|
18462
|
+
.catch(() => {})
|
|
18463
|
+
} catch (err) {
|
|
18464
|
+
process.stderr.write(
|
|
18465
|
+
`telegram gateway: model-menu callback failed: ${(err as Error)?.message ?? String(err)}\n`,
|
|
18466
|
+
)
|
|
18467
|
+
}
|
|
18468
|
+
return
|
|
18469
|
+
}
|
|
18470
|
+
|
|
18333
18471
|
// `cn:cancel:<key>` — cancel a pending Microsoft connect flow (the
|
|
18334
18472
|
// Cancel button on the /connect card). RFC #1873 Phase 2.
|
|
18335
18473
|
if (data.startsWith('cn:')) {
|
|
@@ -102,14 +102,31 @@ export function spoolId(msg: InboundMessage): string {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
interface SpoolRecord {
|
|
105
|
-
t: 'put' | 'ack'
|
|
106
|
-
|
|
105
|
+
t: 'put' | 'ack' | 'esc'
|
|
106
|
+
/** Present on `put`/`ack` (spoolId). Absent on `esc`. */
|
|
107
|
+
id?: string
|
|
107
108
|
/** Present only on `put`. The full inbound to replay. */
|
|
108
109
|
msg?: InboundMessage
|
|
109
110
|
/** Present only on `put`. Owning agent (replay re-pushes per agent). */
|
|
110
111
|
agent?: string
|
|
111
112
|
/** Present only on `put`. ms epoch first-spooled — drives escalation. */
|
|
112
113
|
firstAt?: number
|
|
114
|
+
/** Present only on `esc` — the chat the give-up notice was/would be
|
|
115
|
+
* posted to, and when. Durably records the per-chat escalation-notice
|
|
116
|
+
* window so a burst of undeliverable inbounds (or a multi-restart
|
|
117
|
+
* outage) produces ONE "couldn't deliver" notice per chat, not one
|
|
118
|
+
* per dropped entry. */
|
|
119
|
+
chat?: string | number
|
|
120
|
+
thread?: string
|
|
121
|
+
at?: number
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Stable per-(chat,thread) key for coalescing give-up notices. */
|
|
125
|
+
function escChatKey(msg: InboundMessage): string {
|
|
126
|
+
const threadRaw = msg.meta?.threadId
|
|
127
|
+
const thread =
|
|
128
|
+
typeof threadRaw === 'string' && threadRaw.length > 0 ? threadRaw : '-'
|
|
129
|
+
return `${msg.chatId}:${thread}`
|
|
113
130
|
}
|
|
114
131
|
|
|
115
132
|
export interface InboundSpoolFsSeam {
|
|
@@ -134,6 +151,14 @@ export interface InboundSpoolOptions {
|
|
|
134
151
|
escalateAfterMs?: number
|
|
135
152
|
/** Rewrite-compact the JSONL once it exceeds this. Default 256 KiB. */
|
|
136
153
|
compactAtBytes?: number
|
|
154
|
+
/** Coalescing window for the user-facing "couldn't deliver" notice,
|
|
155
|
+
* per chat. The window SLIDES on every escalation attempt (posted or
|
|
156
|
+
* suppressed), so a sustained burst posts exactly one notice and only
|
|
157
|
+
* re-notifies after the burst goes quiet for this long. Must exceed
|
|
158
|
+
* the rate at which undeliverable entries age out (the 15-min
|
|
159
|
+
* `escalateAfterMs` here) or back-to-back attempts wouldn't coalesce.
|
|
160
|
+
* Default 30 min. */
|
|
161
|
+
escalateNoticeCooldownMs?: number
|
|
137
162
|
}
|
|
138
163
|
|
|
139
164
|
export interface ReplayEntry {
|
|
@@ -165,10 +190,20 @@ export interface InboundSpool {
|
|
|
165
190
|
* finished could land on top of the handback turn. Tombstones the
|
|
166
191
|
* dropped entries durably. */
|
|
167
192
|
dropMatching: (predicate: (id: string) => boolean) => number
|
|
168
|
-
/** Escalate+drop entries older than `escalateAfterMs`.
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
|
|
193
|
+
/** Escalate+drop entries older than `escalateAfterMs`. Every dropped
|
|
194
|
+
* entry is tombstoned (the promise is retracted deterministically),
|
|
195
|
+
* but the user-facing notice is COALESCED per chat: `onEscalate` is
|
|
196
|
+
* called for every dropped entry with `postNotice` indicating whether
|
|
197
|
+
* to actually post the "couldn't deliver" card. `postNotice` is true
|
|
198
|
+
* only for the first escalation to a given chat within
|
|
199
|
+
* `escalateNoticeCooldownMs` — a burst of undeliverable inbounds (e.g.
|
|
200
|
+
* a synthetic re-created every 15 min while the agent is down, across
|
|
201
|
+
* restarts) yields ONE notice, not one per entry. The window is
|
|
202
|
+
* persisted, so it holds across a gateway restart. Returns the count
|
|
203
|
+
* of entries dropped. Safe to call on a timer. */
|
|
204
|
+
sweepEscalations: (
|
|
205
|
+
onEscalate: (e: ReplayEntry, opts: { postNotice: boolean }) => void,
|
|
206
|
+
) => number
|
|
172
207
|
/** Test/observability: count of live (un-acked) ids. */
|
|
173
208
|
liveCount: () => number
|
|
174
209
|
}
|
|
@@ -179,11 +214,18 @@ export function createInboundSpool(opts: InboundSpoolOptions): InboundSpool {
|
|
|
179
214
|
const log = opts.log ?? ((l: string) => process.stderr.write(l))
|
|
180
215
|
const escalateAfterMs = opts.escalateAfterMs ?? 15 * 60 * 1000
|
|
181
216
|
const compactAtBytes = opts.compactAtBytes ?? 256 * 1024
|
|
217
|
+
const escalateNoticeCooldownMs = opts.escalateNoticeCooldownMs ?? 30 * 60 * 1000
|
|
182
218
|
|
|
183
219
|
// In-memory projection of the on-disk log, rebuilt from the file at
|
|
184
220
|
// construction. `live` maps spoolId → the put record (insertion order
|
|
185
221
|
// preserved via the Map). An `ack` deletes from `live`.
|
|
186
222
|
const live = new Map<string, { agent: string; msg: InboundMessage; firstAt: number }>()
|
|
223
|
+
// Per-chat last escalation-ATTEMPT time (posted or suppressed). Drives
|
|
224
|
+
// the sliding coalescing window so a burst of give-up escalations posts
|
|
225
|
+
// one notice. Rebuilt from durable `esc` records at construction so the
|
|
226
|
+
// window survives a gateway restart (the actual 2026-06-09 spam: a
|
|
227
|
+
// synthetic re-aged into the bound every 15 min across many restarts).
|
|
228
|
+
const escAttemptByChat = new Map<string, number>()
|
|
187
229
|
|
|
188
230
|
function parseLine(line: string): SpoolRecord | null {
|
|
189
231
|
const s = line.trim()
|
|
@@ -196,7 +238,13 @@ export function createInboundSpool(opts: InboundSpoolOptions): InboundSpool {
|
|
|
196
238
|
}
|
|
197
239
|
if (rec == null || typeof rec !== 'object') return null
|
|
198
240
|
const r = rec as Record<string, unknown>
|
|
199
|
-
if (r.t !== 'put' && r.t !== 'ack') return null
|
|
241
|
+
if (r.t !== 'put' && r.t !== 'ack' && r.t !== 'esc') return null
|
|
242
|
+
if (r.t === 'esc') {
|
|
243
|
+
// esc records key on chat, not a spoolId.
|
|
244
|
+
if (typeof r.chat !== 'string' && typeof r.chat !== 'number') return null
|
|
245
|
+
if (typeof r.at !== 'number') return null
|
|
246
|
+
return r as unknown as SpoolRecord
|
|
247
|
+
}
|
|
200
248
|
if (typeof r.id !== 'string' || r.id.length === 0) return null
|
|
201
249
|
if (r.t === 'put') {
|
|
202
250
|
if (r.msg == null || typeof r.msg !== 'object') return null
|
|
@@ -209,6 +257,7 @@ export function createInboundSpool(opts: InboundSpoolOptions): InboundSpool {
|
|
|
209
257
|
// Rebuild `live` from the file. Tolerates a torn last line.
|
|
210
258
|
function hydrate(): void {
|
|
211
259
|
live.clear()
|
|
260
|
+
escAttemptByChat.clear()
|
|
212
261
|
if (!fs.existsSync(path)) return
|
|
213
262
|
let raw = ''
|
|
214
263
|
try {
|
|
@@ -221,13 +270,17 @@ export function createInboundSpool(opts: InboundSpoolOptions): InboundSpool {
|
|
|
221
270
|
if (rec == null) continue
|
|
222
271
|
if (rec.t === 'put') {
|
|
223
272
|
// Last put for an id wins; an ack later removes it.
|
|
224
|
-
live.set(rec.id, {
|
|
273
|
+
live.set(rec.id as string, {
|
|
225
274
|
agent: rec.agent as string,
|
|
226
275
|
msg: rec.msg as InboundMessage,
|
|
227
276
|
firstAt: rec.firstAt as number,
|
|
228
277
|
})
|
|
278
|
+
} else if (rec.t === 'esc') {
|
|
279
|
+
// Last escalation-attempt time per chat wins (records are in
|
|
280
|
+
// append order). Restores the sliding window across a restart.
|
|
281
|
+
escAttemptByChat.set(`${rec.chat}:${rec.thread ?? '-'}`, rec.at as number)
|
|
229
282
|
} else {
|
|
230
|
-
live.delete(rec.id)
|
|
283
|
+
live.delete(rec.id as string)
|
|
231
284
|
}
|
|
232
285
|
}
|
|
233
286
|
}
|
|
@@ -269,6 +322,22 @@ export function createInboundSpool(opts: InboundSpoolOptions): InboundSpool {
|
|
|
269
322
|
JSON.stringify({ t: 'put', id, agent: e.agent, msg: e.msg, firstAt: e.firstAt } satisfies SpoolRecord),
|
|
270
323
|
)
|
|
271
324
|
}
|
|
325
|
+
// Preserve the latest escalation-attempt time per chat so the sliding
|
|
326
|
+
// coalescing window isn't reset by compaction (which would let the next
|
|
327
|
+
// burst re-spam). One record per chat — bounded by the chat count.
|
|
328
|
+
for (const [key, at] of escAttemptByChat) {
|
|
329
|
+
const sep = key.lastIndexOf(':')
|
|
330
|
+
const chat = key.slice(0, sep)
|
|
331
|
+
const thread = key.slice(sep + 1)
|
|
332
|
+
lines.push(
|
|
333
|
+
JSON.stringify({
|
|
334
|
+
t: 'esc',
|
|
335
|
+
chat,
|
|
336
|
+
...(thread !== '-' ? { thread } : {}),
|
|
337
|
+
at,
|
|
338
|
+
} satisfies SpoolRecord),
|
|
339
|
+
)
|
|
340
|
+
}
|
|
272
341
|
const tmp = path + '.compact.tmp'
|
|
273
342
|
try {
|
|
274
343
|
fs.writeFileSync(tmp, lines.length ? lines.join('\n') + '\n' : '')
|
|
@@ -328,24 +397,46 @@ export function createInboundSpool(opts: InboundSpoolOptions): InboundSpool {
|
|
|
328
397
|
return n
|
|
329
398
|
},
|
|
330
399
|
sweepEscalations(onEscalate) {
|
|
331
|
-
const
|
|
332
|
-
|
|
400
|
+
const tNow = now()
|
|
401
|
+
const cutoff = tNow - escalateAfterMs
|
|
402
|
+
let dropped = 0
|
|
403
|
+
let posted = 0
|
|
333
404
|
for (const [id, e] of [...live.entries()]) {
|
|
334
405
|
if (e.firstAt > cutoff) continue
|
|
335
406
|
live.delete(id)
|
|
336
407
|
appendRecord({ t: 'ack', id }) // tombstone — promise retracted
|
|
408
|
+
// Coalesce the user-facing notice per chat on a SLIDING window:
|
|
409
|
+
// post only when the last attempt to this chat was longer ago than
|
|
410
|
+
// the cooldown; every attempt (posted or not) slides the window, so
|
|
411
|
+
// a sustained burst stays quiet after the first notice and only
|
|
412
|
+
// re-notifies once the burst goes quiet. Durable via `esc` records.
|
|
413
|
+
const key = escChatKey(e.msg)
|
|
414
|
+
const lastAttempt = escAttemptByChat.get(key)
|
|
415
|
+
const postNotice =
|
|
416
|
+
lastAttempt === undefined || tNow - lastAttempt >= escalateNoticeCooldownMs
|
|
417
|
+
escAttemptByChat.set(key, tNow)
|
|
418
|
+
const threadRaw = e.msg.meta?.threadId
|
|
419
|
+
const thread =
|
|
420
|
+
typeof threadRaw === 'string' && threadRaw.length > 0 ? threadRaw : undefined
|
|
421
|
+
appendRecord({ t: 'esc', chat: e.msg.chatId, thread, at: tNow })
|
|
337
422
|
try {
|
|
338
|
-
onEscalate({ agent: e.agent, msg: e.msg })
|
|
423
|
+
onEscalate({ agent: e.agent, msg: e.msg }, { postNotice })
|
|
339
424
|
} catch (err) {
|
|
340
425
|
log(`inbound-spool: onEscalate threw id=${id}: ${(err as Error).message}\n`)
|
|
341
426
|
}
|
|
342
|
-
|
|
427
|
+
if (postNotice) posted++
|
|
428
|
+
dropped++
|
|
343
429
|
}
|
|
344
|
-
if (
|
|
345
|
-
|
|
430
|
+
if (dropped > 0) {
|
|
431
|
+
const suppressed = dropped - posted
|
|
432
|
+
log(
|
|
433
|
+
`inbound-spool: escalated+dropped ${dropped} undelivered entr${dropped === 1 ? 'y' : 'ies'} ` +
|
|
434
|
+
`(older than ${escalateAfterMs}ms; ${posted} notice${posted === 1 ? '' : 's'} posted` +
|
|
435
|
+
`${suppressed > 0 ? `, ${suppressed} coalesced` : ''})\n`,
|
|
436
|
+
)
|
|
346
437
|
maybeCompact()
|
|
347
438
|
}
|
|
348
|
-
return
|
|
439
|
+
return dropped
|
|
349
440
|
},
|
|
350
441
|
liveCount() {
|
|
351
442
|
return live.size
|