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.
- package/dist/cli/switchroom.js +143 -27
- package/dist/host-control/main.js +1 -1
- package/package.json +3 -2
- package/telegram-plugin/dist/gateway/gateway.js +1152 -296
- package/telegram-plugin/format.ts +74 -1
- package/telegram-plugin/gateway/answer-route-overrides.ts +163 -0
- package/telegram-plugin/gateway/answer-thread-resolve.test.ts +175 -1
- package/telegram-plugin/gateway/answer-thread-resolve.ts +58 -6
- package/telegram-plugin/gateway/escalation-staleness.ts +526 -0
- package/telegram-plugin/gateway/gateway.ts +61 -68
- package/telegram-plugin/gateway/obligation-wiring.ts +91 -3
- package/telegram-plugin/gateway/outbound-send-path.ts +62 -1
- package/telegram-plugin/gateway/reply-route-log.test.ts +134 -0
- package/telegram-plugin/gateway/reply-route-log.ts +118 -0
- package/telegram-plugin/gateway/speech-capture.ts +158 -0
- package/telegram-plugin/gateway/stream-render.ts +1 -1
- package/telegram-plugin/history.ts +21 -0
- package/telegram-plugin/registry/subagents-bugs.test.ts +3 -3
- package/telegram-plugin/render/html-fold.ts +372 -0
- package/telegram-plugin/render/parse.ts +578 -29
- package/telegram-plugin/render/render.ts +14 -13
- package/telegram-plugin/tests/answer-route-side-effect.test.ts +111 -0
- package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +3 -3
- package/telegram-plugin/tests/escalation-staleness.test.ts +1275 -0
- package/telegram-plugin/tests/forwarded-rich-message-coalesce.test.ts +6 -6
- package/telegram-plugin/tests/forwarded-rich-message.test.ts +8 -8
- package/telegram-plugin/tests/history.test.ts +78 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +29 -1
- package/telegram-plugin/tests/orphaned-db-sweep.test.ts +17 -1
- package/telegram-plugin/tests/render/html-dialect-content-loss.test.ts +326 -0
- package/telegram-plugin/tests/render/html-dialect.test.ts +283 -0
- package/telegram-plugin/tests/render/parse.test.ts +9 -5
- package/telegram-plugin/tests/send-reply-golden.test.ts +290 -3
- package/telegram-plugin/tests/speech-capture.test.ts +296 -0
- package/telegram-plugin/tests/status-pin.test.ts +2 -2
- package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +2 -2
- package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +2 -2
- package/telegram-plugin/tests/telegram-format.test.ts +52 -0
- package/telegram-plugin/tests/tts-normalize.test.ts +114 -0
- package/telegram-plugin/tests/turn-supersede-finalizes-prior-card.test.ts +1 -1
- package/telegram-plugin/tests/voice-normalize-text.test.ts +89 -0
- package/telegram-plugin/tests/worker-origin-gap-dispatch.test.ts +1 -1
- package/telegram-plugin/tts-normalize.ts +47 -9
- package/telegram-plugin/uat/scenarios/jtbd-supergroup-reply-channel.test.ts +1 -1
- package/telegram-plugin/voice-normalize-text.ts +48 -9
- 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
|
+
}
|