switchroom 0.21.9 → 0.21.11

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 (46) hide show
  1. package/dist/cli/switchroom.js +143 -27
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +3 -2
  4. package/telegram-plugin/dist/gateway/gateway.js +1152 -296
  5. package/telegram-plugin/format.ts +74 -1
  6. package/telegram-plugin/gateway/answer-route-overrides.ts +163 -0
  7. package/telegram-plugin/gateway/answer-thread-resolve.test.ts +175 -1
  8. package/telegram-plugin/gateway/answer-thread-resolve.ts +58 -6
  9. package/telegram-plugin/gateway/escalation-staleness.ts +526 -0
  10. package/telegram-plugin/gateway/gateway.ts +61 -68
  11. package/telegram-plugin/gateway/obligation-wiring.ts +91 -3
  12. package/telegram-plugin/gateway/outbound-send-path.ts +62 -1
  13. package/telegram-plugin/gateway/reply-route-log.test.ts +134 -0
  14. package/telegram-plugin/gateway/reply-route-log.ts +118 -0
  15. package/telegram-plugin/gateway/speech-capture.ts +158 -0
  16. package/telegram-plugin/gateway/stream-render.ts +1 -1
  17. package/telegram-plugin/history.ts +21 -0
  18. package/telegram-plugin/registry/subagents-bugs.test.ts +3 -3
  19. package/telegram-plugin/render/html-fold.ts +372 -0
  20. package/telegram-plugin/render/parse.ts +578 -29
  21. package/telegram-plugin/render/render.ts +14 -13
  22. package/telegram-plugin/tests/answer-route-side-effect.test.ts +111 -0
  23. package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +3 -3
  24. package/telegram-plugin/tests/escalation-staleness.test.ts +1275 -0
  25. package/telegram-plugin/tests/forwarded-rich-message-coalesce.test.ts +6 -6
  26. package/telegram-plugin/tests/forwarded-rich-message.test.ts +8 -8
  27. package/telegram-plugin/tests/history.test.ts +78 -0
  28. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +29 -1
  29. package/telegram-plugin/tests/orphaned-db-sweep.test.ts +17 -1
  30. package/telegram-plugin/tests/render/html-dialect-content-loss.test.ts +326 -0
  31. package/telegram-plugin/tests/render/html-dialect.test.ts +283 -0
  32. package/telegram-plugin/tests/render/parse.test.ts +9 -5
  33. package/telegram-plugin/tests/send-reply-golden.test.ts +290 -3
  34. package/telegram-plugin/tests/speech-capture.test.ts +296 -0
  35. package/telegram-plugin/tests/status-pin.test.ts +2 -2
  36. package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +2 -2
  37. package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +2 -2
  38. package/telegram-plugin/tests/telegram-format.test.ts +52 -0
  39. package/telegram-plugin/tests/tts-normalize.test.ts +114 -0
  40. package/telegram-plugin/tests/turn-supersede-finalizes-prior-card.test.ts +1 -1
  41. package/telegram-plugin/tests/voice-normalize-text.test.ts +89 -0
  42. package/telegram-plugin/tests/worker-origin-gap-dispatch.test.ts +1 -1
  43. package/telegram-plugin/tts-normalize.ts +47 -9
  44. package/telegram-plugin/uat/scenarios/jtbd-supergroup-reply-channel.test.ts +1 -1
  45. package/telegram-plugin/voice-normalize-text.ts +48 -9
  46. package/telegram-plugin/worker-activity-feed.ts +2 -2
@@ -0,0 +1,526 @@
1
+ /**
2
+ * escalation-staleness.ts — the two guards that stand an obligation ESCALATION
3
+ * down when the agent has, in fact, already answered.
4
+ *
5
+ * The escalate branch of the obligation sweep sends a user-visible
6
+ * "⚠️ I may have missed an earlier message" nudge. That nudge is only correct
7
+ * when the agent genuinely never answered. Two mechanisms in the pre-fix code
8
+ * let it fire on top of an answer the user had already received. Both are
9
+ * reproduced from a production `gateway-supervisor.log` (the chat id and the
10
+ * message bodies are deliberately NOT reproduced here — only thread ids,
11
+ * message numbers and timings, which is all the derivation needs):
12
+ *
13
+ * 1. REROUTED ANSWER. The staleness check was keyed on the obligation's
14
+ * `threadId`. When the model names a topic on its `reply` and the
15
+ * framework's topic authority overrides it — the router logs exactly this,
16
+ * `EXPLICIT_OVERRIDDEN(model→N,routed→M)` — the answer is written to
17
+ * history under thread M while the obligation lives under thread N, so a
18
+ * thread-N query can never see it.
19
+ *
20
+ * 2026-08-10 06:36:07.921, obligation `…:4#5191` (thread 4):
21
+ * `EXPLICIT_OVERRIDDEN(model→4,routed→635)` — the model was answering the
22
+ * thread-4 obligation, the answer went to thread 635, and the nag fired
23
+ * 126 ms later at 06:36:08.047.
24
+ * 2026-08-10 07:52:36.026, obligation `…:3#5200` (thread 3):
25
+ * `EXPLICIT_OVERRIDDEN(model→3,routed→-)` — answer written with
26
+ * `thread_id IS NULL`; the nag had already fired at 07:52:34.400.
27
+ *
28
+ * Fix: `answeredSinceOpen` keeps the thread scope and adds a fallback to
29
+ * the threads an answer ADDRESSED TO THIS OBLIGATION'S TOPIC was actually
30
+ * routed to, read from the router's own override record
31
+ * (`answer-route-overrides.ts`).
32
+ *
33
+ * NOT a chat-wide fallback, and NOT an open-ended one at EITHER end. Three
34
+ * weaker forms were tried on this branch and every one silently drops a
35
+ * real message:
36
+ *
37
+ * (i) CHAT-WIDE ("did anything long land in this chat since openedAt") has
38
+ * no relationship to the obligation beyond the chat id, so in a busy forum
39
+ * it closes an obligation in topic A because an unrelated answer landed in
40
+ * topic B. The repo already states that principle for the sibling
41
+ * predicate (`turn-flush-suppression.ts`, "a background worker's
42
+ * progress_update … or a reply in a DIFFERENT forum topic all suppressed
43
+ * the flush, and the branch then CLOSED the delivery obligation — the
44
+ * user's real answer was dropped").
45
+ *
46
+ * (ii) OVERRIDE-GATED BUT CUT AT `openedAt` — "an override to topic M
47
+ * exists, therefore accept anything ≥200 chars ever delivered in M since
48
+ * this obligation opened" — is the same defect wearing a gate. The override
49
+ * proves a reroute HAPPENED; it says nothing about a delivery minutes
50
+ * later. The counter-example is 2026-08-13 obligation `…:4#5462`
51
+ * (thread 4, opened 03:50:02.807, escalated 04:03:06.140). Its window
52
+ * contains THREE `EXPLICIT_OVERRIDDEN(model→4,routed→3)` records
53
+ * (03:50:44.201, 03:51:43.108, 03:53:11.225) AND a later, unrelated 295-char
54
+ * delivery in thread 3 at 04:02:51.987 answering a DIFFERENT question
55
+ * (`via=origin` to turn `…:3#5480`). Cut at `openedAt`, the fallback pairs
56
+ * the stale override with that unrelated delivery and closes topic 4's
57
+ * genuinely-unanswered message in silence — pre-fix this escalates
58
+ * correctly, so that shape is a REGRESSION, not a fix.
59
+ *
60
+ * (iii) OVERRIDE-GATED, CUT AT `atMs`, BUT OPEN-ENDED FORWARD — "this
61
+ * override is fresh, therefore accept anything ≥200 chars delivered in M
62
+ * from `atMs` onwards, however long onwards turns out to be" — is the SAME
63
+ * defect once more, now leaking out the other end of the window. Freshness
64
+ * is judged at the settle gate's `firstAt` (the instant the question was
65
+ * FIRST asked) while the history row is read at the RE-CHECK, and the gates
66
+ * above this branch can starve the sweep for minutes between the two. So
67
+ * the override is fresh, the delivery is not, and they are paired anyway:
68
+ * a short reply is rerouted to topic M at T+0, the obligation correctly
69
+ * stays open (under the substantive floor), an unrelated ≥200-char answer
70
+ * lands in M at T+90, the next sweep runs at T+107 and closes the
71
+ * obligation `via=reroute`. Same shape as (ii), reached by a different
72
+ * route. Test (f).
73
+ *
74
+ * So the fallback's window is bounded at BOTH ends by the OVERRIDE'S OWN
75
+ * `atMs` — `[atMs, atMs + rerouteMatchWindowMs]` — and an override is only
76
+ * consulted while it is itself fresh (`resolveRerouteMatchWindowMs`). Both
77
+ * ends are absolute instants fixed by the record, so neither moves when the
78
+ * sweep is starved. An override licenses a look for the answer THAT routing
79
+ * produced, in the moments after it — nothing more. On 2026-08-13 the
80
+ * newest override is 594.9 s stale at the decision, so no fallback runs at
81
+ * all and the escalation fires, as it must.
82
+ *
83
+ * 2. NO SETTLE. The check is a point-in-time read of history, but the answer
84
+ * is frequently still IN FLIGHT at the instant the sweep decides: the reply
85
+ * tool has been invoked (or is about to be) and its history row does not
86
+ * exist yet. Same two 2026-08-10 incidents: 06:36 (reply invoked :07.919,
87
+ * escalation decided :08.047, answer delivered :09.281 — 1.23 s AFTER the
88
+ * decision) and 07:52 (decision :34.400, reply invoked :36.026, delivered
89
+ * :37.209 — 2.81 s after). A backward-looking widening of the cutoff cannot
90
+ * fix this: the cutoff is already `openedAt`, minutes in the past, so a
91
+ * delivered row would have matched. The row simply is not there yet. Fix:
92
+ * `createEscalationSettleGate` requires the staleness check to have read
93
+ * "not answered" ACROSS a settle window before the nudge is allowed out —
94
+ * the escalation is deferred once, re-checked on a later sweep, and only
95
+ * sent if the answer still has not landed.
96
+ *
97
+ * Both incidents needed BOTH guards: each answer was rerouted AND still in
98
+ * flight at the decision instant, so neither guard alone suppresses either nag.
99
+ *
100
+ * Both guards are deliberately bounded: the settle gate delays a genuine
101
+ * escalation by the settle window and no more, the reroute fallback only looks
102
+ * where the router says this topic's answer went, and both keep the caller's
103
+ * substantive-length floor — so an escalation for a genuinely unanswered
104
+ * message still fires.
105
+ */
106
+ import type { AnswerRouteOverrides } from './answer-route-overrides.js'
107
+
108
+ /**
109
+ * Escalation settle window, in milliseconds.
110
+ *
111
+ * Derived from the observed decision→delivery lag on the confirmed false
112
+ * escalations where the answer was in flight at decision time:
113
+ *
114
+ * - 2026-08-10 06:36:08.047 decision → 06:36:09.281 delivery = 1,234 ms
115
+ * - 2026-08-10 07:52:34.400 decision → 07:52:37.209 delivery = 2,809 ms
116
+ *
117
+ * The window must (a) exceed the WORST observed lag with real margin — a send
118
+ * can additionally sit behind the per-chat send-gate pacing — and (b) span at
119
+ * least one 5,000 ms sweep tick, or no re-check ever runs and the gate is a pure
120
+ * delay. 3 × 2,809 = 8,427 ms, rounded up to the nearest 500 ms → 8,500 ms,
121
+ * which satisfies both.
122
+ *
123
+ * The upper bound is the genuinely-unanswered case that MUST still escalate:
124
+ * 2026-08-12 09:08:14.524 decision → the agent's real answer at 09:08:56 =
125
+ * 41,331 ms. 8,500 ms sits 4.9× below that, so a settle re-check cannot swallow
126
+ * it.
127
+ *
128
+ * Kill switch: 0 disables the gate (pre-fix, escalate on the first decision).
129
+ */
130
+ export const OBLIGATION_ESCALATE_SETTLE_MS_DEFAULT = 8_500
131
+
132
+ /**
133
+ * Hard ceiling on the settle window.
134
+ *
135
+ * The gate trades a bounded DELAY of a genuine nudge for suppression of a false
136
+ * one, and that trade is only honest while the delay stays small next to the
137
+ * ladder that precedes it. A fat-fingered
138
+ * `SWITCHROOM_OBLIGATION_ESCALATE_SETTLE_MS=85000000` would otherwise silently
139
+ * suppress every escalation for a day — a config typo turning a guard into an
140
+ * outage. 60 s is ~7× the derived default and still well under the represent
141
+ * ladder's own minutes-long cadence, so any legitimate tuning fits under it and
142
+ * anything above it is a typo, not an intent. Values above the ceiling are
143
+ * CLAMPED (not rejected): clamping keeps the guard working, whereas falling back
144
+ * to the default would silently ignore a deliberate 90 s choice.
145
+ */
146
+ export const OBLIGATION_ESCALATE_SETTLE_MS_MAX = 60_000
147
+
148
+ /**
149
+ * Slack added to the settle window to get the reroute-match window (below).
150
+ *
151
+ * Two 5,000 ms sweep ticks. The window is measured from the settle gate's own
152
+ * `firstAt` — the instant the decision FIRST read "unanswered" — so this slack
153
+ * only has to cover the jitter between the routing decision and that first
154
+ * read: the sweep runs on a 5,000 ms interval, and the read can land anywhere
155
+ * inside a tick, so one tick of phase plus one more for a busy tick.
156
+ *
157
+ * It deliberately does NOT have to cover the gap to the RE-check. That gap is
158
+ * unbounded in principle — the sweep's earlier gates (`turnInFlightForGate`,
159
+ * the background-work / session-busy defer, and the escalate/represent graces)
160
+ * can SKIP ticks entirely, not merely delay them, for minutes at a time — which
161
+ * is exactly why the anchor is `firstAt` and not the re-check's `now`. See
162
+ * `EscalationSettleGate.firstAt`.
163
+ */
164
+ export const REROUTE_MATCH_GRACE_MS = 10_000
165
+
166
+ /**
167
+ * Hard ceiling on the reroute-match window.
168
+ *
169
+ * Same reasoning as `OBLIGATION_ESCALATE_SETTLE_MS_MAX`, mirrored for the other
170
+ * direction of harm: a fat-fingered `SWITCHROOM_OBLIGATION_REROUTE_MATCH_MS`
171
+ * widens the window in which an unrelated delivery can be mistaken for this
172
+ * obligation's rerouted answer — i.e. it widens the SILENT-CLOSE exposure, the
173
+ * one failure this module treats as unacceptable. The ceiling is the largest
174
+ * value the derivation itself can ever produce (the clamped maximum settle
175
+ * window plus the grace), so every legitimate tuning fits under it and anything
176
+ * above it is a typo. Clamped, not rejected, for the same reason as the settle
177
+ * ceiling: a clamped guard still guards.
178
+ */
179
+ export const OBLIGATION_REROUTE_MATCH_MS_MAX =
180
+ OBLIGATION_ESCALATE_SETTLE_MS_MAX + REROUTE_MATCH_GRACE_MS
181
+
182
+ /**
183
+ * How stale an `EXPLICIT_OVERRIDDEN` record may be and still license a look in
184
+ * the thread it names.
185
+ *
186
+ * This bound is the whole correctness argument for the reroute fallback, so it
187
+ * is derived, not picked. Staleness is measured from the settle gate's
188
+ * `firstAt`, so a legitimate override is at most `settleMs` old (the answer was
189
+ * in flight when that first decision deferred), plus up to two sweep ticks of
190
+ * scheduling slack — `REROUTE_MATCH_GRACE_MS`. At the default that is
191
+ * 8,500 + 10,000 = 18,500 ms.
192
+ *
193
+ * Both justifying incidents sit far inside it (2026-08-10 06:36 override
194
+ * :07.921 → delivery :09.281 = 1.36 s; 07:52 override :36.026 → delivery
195
+ * :37.209 = 1.18 s), and the counter-example sits far outside it (2026-08-13
196
+ * newest override 03:53:11.225 → the unrelated delivery at 04:02:51.987 =
197
+ * 594.8 s, a 32× margin over the window). Nothing observed lives in between.
198
+ *
199
+ * KILL SWITCH: `SWITCHROOM_OBLIGATION_REROUTE_MATCH_MS=0` disables the reroute
200
+ * fallback entirely (thread scope only — pre-fix behaviour for this half of the
201
+ * fix). It is its OWN switch: `SWITCHROOM_OBLIGATION_ESCALATE_SETTLE_MS=0`
202
+ * disables the settle gate and NOTHING ELSE. The fallback needs one because it
203
+ * is the only mechanism here that can close a genuinely unanswered obligation
204
+ * silently; the settle gate can only ever DELAY a nudge. A non-numeric or
205
+ * negative value falls back to the derivation rather than silently disabling or
206
+ * widening the guard; a value above `OBLIGATION_REROUTE_MATCH_MS_MAX` is
207
+ * clamped.
208
+ *
209
+ * The window bounds the fallback TWICE, and the distinction matters because one
210
+ * of the two was missing and re-opened the silent close (#4681, form (iii) in
211
+ * the module header):
212
+ *
213
+ * - it bounds which OVERRIDES are consulted — no record older than the window
214
+ * (measured back from `anchorMs`) licenses anything; and
215
+ * - it bounds which DELIVERIES a consulted override may be paired with — only
216
+ * rows inside `[atMs, atMs + window]`, the interval in which the answer that
217
+ * routing produced could actually land.
218
+ *
219
+ * The second is not implied by the first. Freshness is judged at `anchorMs` (the
220
+ * settle gate's `firstAt`) while the history row is read at the re-check, and the
221
+ * sweep can be starved for minutes in between — so without the delivery bound a
222
+ * legitimately fresh override reaches arbitrarily far forward.
223
+ *
224
+ * RESIDUAL, stated plainly: inside `[atMs, atMs + window]` the fallback still
225
+ * cannot tell the rerouted answer from a second ≥200-char delivery that lands in
226
+ * the same thread in those few seconds — the override carries no message id to
227
+ * tie it to one row. That interval is fixed by the record's own timestamp, so it
228
+ * does not widen when the sweep is starved; it is what bounds the exposure, and
229
+ * the exposure is bounded, not eliminated.
230
+ */
231
+ export function resolveRerouteMatchWindowMs(
232
+ settleMs: number,
233
+ env: Record<string, string | undefined> = process.env,
234
+ ): number {
235
+ const derived = Math.max(settleMs, 0) + REROUTE_MATCH_GRACE_MS
236
+ const raw = env.SWITCHROOM_OBLIGATION_REROUTE_MATCH_MS
237
+ // `.trim()`, not `=== ''`: `Number(' ') === 0`, so a whitespace-only value —
238
+ // the shape a stray `KEY=" "` in an env file produces — would otherwise read
239
+ // as a deliberate 0 and silently DISABLE the fallback with no log.
240
+ if (raw == null || raw.trim() === '') return derived
241
+ const n = Number(raw)
242
+ if (!(Number.isFinite(n) && n >= 0)) return derived
243
+ return Math.min(n, OBLIGATION_REROUTE_MATCH_MS_MAX)
244
+ }
245
+
246
+ /**
247
+ * Resolve the settle window from the environment.
248
+ *
249
+ * Lives here rather than beside the other obligation constants in gateway.ts:
250
+ * that file is under the anti-inflation line ratchet (#2996 P0.5) with zero
251
+ * headroom, and the ratchet's whole point is that new logic lands in a module.
252
+ * `SWITCHROOM_OBLIGATION_ESCALATE_SETTLE_MS=0` is the kill switch (pre-fix
253
+ * behaviour: escalate on the first decision); a non-numeric or negative value
254
+ * falls back to the default rather than silently disabling the guard; a value
255
+ * above `OBLIGATION_ESCALATE_SETTLE_MS_MAX` is clamped to it.
256
+ */
257
+ export function resolveEscalateSettleMs(
258
+ env: Record<string, string | undefined> = process.env,
259
+ ): number {
260
+ const raw = env.SWITCHROOM_OBLIGATION_ESCALATE_SETTLE_MS
261
+ // `.trim()`, not `=== ''` — `Number(' ') === 0`, which would read as the kill
262
+ // switch and silently disable the gate. See `resolveRerouteMatchWindowMs`.
263
+ if (raw == null || raw.trim() === '') return OBLIGATION_ESCALATE_SETTLE_MS_DEFAULT
264
+ const n = Number(raw)
265
+ if (!(Number.isFinite(n) && n >= 0)) return OBLIGATION_ESCALATE_SETTLE_MS_DEFAULT
266
+ return Math.min(n, OBLIGATION_ESCALATE_SETTLE_MS_MAX)
267
+ }
268
+
269
+ export interface EscalationStalenessDeps {
270
+ /** History available? When false the check reports "not answered" (safe: never suppresses). */
271
+ historyEnabled: boolean
272
+ /**
273
+ * The history predicate, open-ended forward. `threadId === undefined` means
274
+ * CHAT scope (any thread); an explicit number/null scopes to that thread.
275
+ *
276
+ * Used ONLY for the obligation's own topic, where open-ended is correct: that
277
+ * scope is the obligation itself, so anything substantive delivered there
278
+ * since it opened is its answer. The reroute fallback must not use this — see
279
+ * `hasOutboundDeliveredBetween`.
280
+ */
281
+ hasOutboundDeliveredSince: (
282
+ chatId: string,
283
+ sinceMs: number,
284
+ threadId?: number | null,
285
+ ) => boolean
286
+ /**
287
+ * The same predicate BOUNDED AT BOTH ENDS — `[sinceMs, untilMs]`.
288
+ *
289
+ * The reroute fallback's only history call. An `EXPLICIT_OVERRIDDEN` record is
290
+ * evidence about ONE INSTANT: at `atMs` the router sent this topic's answer
291
+ * elsewhere. It licenses a look for the delivery THAT routing produced, which
292
+ * lands within the match window of `atMs` — and for nothing else the routed
293
+ * topic carries before or after. A forward-open query turns that instant of
294
+ * evidence into a standing licence over the routed topic, which is precisely
295
+ * the silent close this module exists to prevent.
296
+ *
297
+ * Deliberately a SEPARATE dep rather than an optional argument on the one
298
+ * above: an optional bound is one a caller — or a test stub — can drop without
299
+ * anything failing, and the bound is load-bearing.
300
+ */
301
+ hasOutboundDeliveredBetween: (
302
+ chatId: string,
303
+ sinceMs: number,
304
+ untilMs: number,
305
+ threadId?: number | null,
306
+ ) => boolean
307
+ /** The router's explicit-thread override record (answer-route-overrides.ts). */
308
+ routeOverrides: Pick<AnswerRouteOverrides, 'routedOverridesSince' | 'newestOverrideSince'>
309
+ /**
310
+ * The instant the freshness window is measured BACK from — the settle gate's
311
+ * `firstAt` for this obligation when a settle window is already open, else
312
+ * this decision's `now`.
313
+ *
314
+ * NOT the re-check instant. The sweep's earlier gates can skip ticks outright
315
+ * (an in-flight turn is unbounded; the background-work/session-busy defer is
316
+ * bounded at 20 min; the escalate and represent graces add tens of seconds),
317
+ * so the decision that consults the override record can run minutes after the
318
+ * one that deferred. Anchored at `now`, a record that was fresh when the
319
+ * question "has this been answered?" was FIRST asked reads as stale purely
320
+ * because the sweep was starved — and the nudge fires on top of the answer.
321
+ * Anchored at `firstAt`, the window means what its derivation says it means.
322
+ */
323
+ anchorMs: number
324
+ /** How stale an override may be — `resolveRerouteMatchWindowMs(settleMs)`.
325
+ * `<= 0` disables the reroute fallback entirely (kill switch). */
326
+ rerouteMatchWindowMs: number
327
+ }
328
+
329
+ export interface EscalationStalenessObligation {
330
+ chatId: string
331
+ openedAt: number
332
+ threadId?: number | null
333
+ }
334
+
335
+ /** How the answer was found — carried into the sweep's log so a reroute-scope
336
+ * hit is distinguishable from a plain same-topic hit in production. */
337
+ export type AnsweredVia = 'thread' | 'reroute'
338
+
339
+ export interface AnsweredSinceOpenResult {
340
+ answered: boolean
341
+ via: AnsweredVia | null
342
+ /** The thread the reroute-scope hit was found in. Only set when `via` is 'reroute'. */
343
+ routedThreadId?: number | null
344
+ /**
345
+ * Age (relative to `anchorMs`) of the NEWEST override the freshness bound
346
+ * rejected, when the fallback found nothing AND a record for this obligation
347
+ * existed. Absent when there was simply no record.
348
+ *
349
+ * Negative-path telemetry: without it "no reroute on record" and "a reroute
350
+ * was on record and the bound threw it away" are the same silent result, and
351
+ * the bound — which is the whole correctness argument for the fallback — is
352
+ * unmeasurable in production. The caller logs it.
353
+ */
354
+ staleOverrideAgeMs?: number
355
+ }
356
+
357
+ /**
358
+ * True when a substantive outbound that can be tied to THIS obligation was
359
+ * delivered since it was opened — i.e. the agent answered and the nudge would be
360
+ * redundant.
361
+ *
362
+ * Two scopes, in order:
363
+ *
364
+ * 1. THREAD — the obligation's own topic. The precise, pre-existing check.
365
+ * (An obligation with `threadId === undefined` — a DM, or a turn the
366
+ * gateway never resolved a topic for — has always meant "any thread" to
367
+ * `hasOutboundDeliveredSince`; that is unchanged.)
368
+ * 2. REROUTE — only the topics the router RECORDED an answer addressed to this
369
+ * obligation's topic as having been routed to
370
+ * (`EXPLICIT_OVERRIDDEN(model→N,routed→M)`), and only for as long as that
371
+ * record is FRESH (`rerouteMatchWindowMs` before `anchorMs`). Each override
372
+ * is queried across ITS OWN `[atMs, atMs + rerouteMatchWindowMs]` — never
373
+ * from `openedAt`, and never open-ended forward: the record licenses a look
374
+ * for the answer that routing produced, in the moments after it, not for
375
+ * anything the thread has carried since the obligation opened nor for
376
+ * anything it carries minutes later. No fresh override ⇒ no second query ⇒
377
+ * an unrelated message in another topic can never stand this escalation
378
+ * down. All three weaker cutoffs are counter-exampled in the module header
379
+ * with the log lines that break them.
380
+ *
381
+ * Both scopes keep the caller's substantive-length floor (200 chars in the
382
+ * escalate branch, so a bare ack never stands an escalation down), and neither
383
+ * can ever reach back past `openedAt`.
384
+ *
385
+ * Falls back to "not answered" (never suppresses) when history is unavailable.
386
+ */
387
+ export function answeredSinceOpen(
388
+ o: EscalationStalenessObligation,
389
+ deps: EscalationStalenessDeps,
390
+ ): AnsweredSinceOpenResult {
391
+ const NOT_ANSWERED: AnsweredSinceOpenResult = { answered: false, via: null }
392
+ if (!deps.historyEnabled) return NOT_ANSWERED
393
+ if (deps.hasOutboundDeliveredSince(o.chatId, o.openedAt, o.threadId)) {
394
+ return { answered: true, via: 'thread' }
395
+ }
396
+ // Kill switch. A zero/negative window means "no reroute fallback at all", not
397
+ // "a zero-width window an override recorded at this very instant slips
398
+ // through" — an off-switch that still fires for one timing is not one.
399
+ if (!(deps.rerouteMatchWindowMs > 0)) return NOT_ANSWERED
400
+ // Freshness floor: never older than the window (measured back from the FIRST
401
+ // "unanswered" read — see `anchorMs`), and never before the obligation
402
+ // existed. A stale override licenses nothing.
403
+ const notBefore = Math.max(o.openedAt, deps.anchorMs - deps.rerouteMatchWindowMs)
404
+ for (const ovr of deps.routeOverrides.routedOverridesSince(o.chatId, o.threadId, notBefore)) {
405
+ // `routedThreadId` is already the history's thread semantics: a number for a
406
+ // topic, `null` for the chat root (`thread_id IS NULL`). Never `undefined`,
407
+ // which would re-open the chat-wide any-thread query this guard avoids.
408
+ //
409
+ // BOTH ends of the query are the override's OWN instant ± the match window.
410
+ // Lower: the delivery cannot predate the routing that produced it (and never
411
+ // reaches back past `openedAt`) — see the module header for the incident an
412
+ // `openedAt` cutoff regresses. Upper: the record is evidence about that
413
+ // instant only, so it licenses a look across the window in which that answer
414
+ // could land and no further — see the header's form (iii).
415
+ const since = Math.max(ovr.atMs, o.openedAt)
416
+ const until = ovr.atMs + deps.rerouteMatchWindowMs
417
+ if (deps.hasOutboundDeliveredBetween(o.chatId, since, until, ovr.routedThreadId)) {
418
+ return { answered: true, via: 'reroute', routedThreadId: ovr.routedThreadId }
419
+ }
420
+ }
421
+ // Nothing matched. Distinguish "no record" from "a record the bound rejected"
422
+ // so the bound is observable in production (the caller logs the age).
423
+ const newest = deps.routeOverrides.newestOverrideSince(o.chatId, o.threadId, o.openedAt)
424
+ if (newest != null && newest.atMs < notBefore) {
425
+ return { answered: false, via: null, staleOverrideAgeMs: Math.max(0, deps.anchorMs - newest.atMs) }
426
+ }
427
+ return NOT_ANSWERED
428
+ }
429
+
430
+ export interface EscalationSettleGate {
431
+ /**
432
+ * Called each time the sweep reaches the escalate decision for `id` and the
433
+ * staleness check said "not answered". Returns true to DEFER (leave the
434
+ * obligation open; a later sweep re-checks), false to proceed with the nudge.
435
+ *
436
+ * `openedAt` is the obligation's open instant, carried as an EPOCH: an
437
+ * obligation closed and later RE-OPENED under the same origin turn id gets a
438
+ * new `openedAt`, and a mismatch resets the window — so a stale entry from the
439
+ * previous episode can never let the new one's first escalation skip its
440
+ * re-check.
441
+ */
442
+ shouldDefer(id: string, now: number, openedAt: number): boolean
443
+ /**
444
+ * The instant this obligation's OPEN settle window started — the decision that
445
+ * first read "not answered" for this episode. `undefined` when no window is
446
+ * open for `id` (first decision, gate disabled, or the entry was evicted).
447
+ *
448
+ * This is the freshness anchor for the reroute fallback (`anchorMs`). Read
449
+ * BEFORE `shouldDefer`, so the first decision anchors at its own `now` and
450
+ * every re-check anchors at that same instant however many ticks were skipped
451
+ * in between. `openedAt` is matched for the same reason `shouldDefer` matches
452
+ * it: a re-opened obligation under the same origin id must not inherit the
453
+ * previous episode's anchor.
454
+ *
455
+ * Fails in the safe direction: no entry ⇒ the caller anchors at `now`, which
456
+ * can only make an override look OLDER, i.e. over-escalate.
457
+ */
458
+ firstAt(id: string, openedAt: number): number | undefined
459
+ /** Forget `id` — call on EVERY obligation terminal (silent close, cancel,
460
+ * escalation driven), so the map never retains closed obligations. */
461
+ clear(id: string): void
462
+ /** Live entry count. Test/diagnostic surface. */
463
+ size(): number
464
+ }
465
+
466
+ /**
467
+ * A settle gate: an escalation is only sent once the "no answer delivered" read
468
+ * has held for `settleMs`.
469
+ *
470
+ * The first decision records `now` and defers. Subsequent decisions for the same
471
+ * id proceed once `settleMs` has elapsed since that first read. Because the
472
+ * caller only reaches this gate when the staleness check reported "not
473
+ * answered", proceeding means the check was false at BOTH ends of the window —
474
+ * so an answer that lands anywhere inside it suppresses the nudge instead.
475
+ *
476
+ * Bounded by construction: the delay is exactly `settleMs`; it cannot grow, and
477
+ * a genuinely unanswered obligation still escalates one settle window later.
478
+ * `settleMs <= 0` disables the gate (never defers).
479
+ *
480
+ * The id map is bounded and evicted oldest-INSERTED-first (FIFO — entries are
481
+ * not re-inserted on access, so this is deliberately not an LRU) so a long-lived
482
+ * gateway cannot grow it without limit. Ids are per-obligation origin turn ids,
483
+ * so an evicted entry can only ever cost one extra settle window, never a wrong
484
+ * decision for another obligation.
485
+ */
486
+ export function createEscalationSettleGate(
487
+ settleMs: number,
488
+ maxKeys = 256,
489
+ ): EscalationSettleGate {
490
+ const entries = new Map<string, { firstAt: number; openedAt: number }>()
491
+ return {
492
+ shouldDefer(id: string, now: number, openedAt: number): boolean {
493
+ if (!(settleMs > 0)) return false
494
+ const prev = entries.get(id)
495
+ // Absent, or belonging to a PREVIOUS episode of the same origin id →
496
+ // start a fresh window.
497
+ if (prev == null || prev.openedAt !== openedAt) {
498
+ entries.set(id, { firstAt: now, openedAt })
499
+ while (entries.size > maxKeys) {
500
+ const oldest = entries.keys().next().value
501
+ if (oldest === undefined) break
502
+ entries.delete(oldest)
503
+ }
504
+ return true
505
+ }
506
+ // A clock that jumped backwards must not pin the gate open forever:
507
+ // re-anchor and defer exactly one more window.
508
+ if (now < prev.firstAt) {
509
+ entries.set(id, { firstAt: now, openedAt })
510
+ return true
511
+ }
512
+ return now - prev.firstAt < settleMs
513
+ },
514
+ firstAt(id: string, openedAt: number): number | undefined {
515
+ const prev = entries.get(id)
516
+ if (prev == null || prev.openedAt !== openedAt) return undefined
517
+ return prev.firstAt
518
+ },
519
+ clear(id: string): void {
520
+ entries.delete(id)
521
+ },
522
+ size(): number {
523
+ return entries.size
524
+ },
525
+ }
526
+ }