switchroom 0.20.22 → 0.21.1
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 +1952 -1538
- package/dist/host-control/main.js +285 -121
- package/package.json +1 -1
- package/skills/switchroom-release/SKILL.md +12 -1
- package/telegram-plugin/dist/gateway/gateway.js +1551 -850
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +2 -2
- package/telegram-plugin/gateway/boot-beacon.ts +9 -2
- package/telegram-plugin/gateway/gateway-heartbeat.ts +4 -3
- package/telegram-plugin/gateway/gateway.ts +50 -15
- package/telegram-plugin/gateway/inbound-router.ts +31 -6
- package/telegram-plugin/gateway/missed-approvals-store.ts +2 -2
- package/telegram-plugin/gateway/pending-card-store.ts +2 -2
- package/telegram-plugin/gateway/privacy-state.ts +2 -2
- package/telegram-plugin/gateway/scoped-grant-store.ts +2 -2
- package/telegram-plugin/gateway/system-message-observer.ts +418 -0
- package/telegram-plugin/gateway/turn-active-marker.ts +3 -4
- package/telegram-plugin/history.ts +178 -11
- package/telegram-plugin/registry/turns-schema.ts +8 -2
- package/telegram-plugin/shared/sent-text-capture.ts +191 -0
- package/telegram-plugin/tests/buzz-mirror.test.ts +12 -1
- package/telegram-plugin/tests/card-history-lane.test.ts +514 -0
- package/telegram-plugin/tests/sent-text-capture.test.ts +526 -0
- package/telegram-plugin/tests/system-message-observer.test.ts +225 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* system-message-observer.ts — make CARD message ids resolvable (#4571).
|
|
3
|
+
*
|
|
4
|
+
* The problem
|
|
5
|
+
* -----------
|
|
6
|
+
* Only two things ever reached `history.db`: an inbound message, and an
|
|
7
|
+
* outbound that flowed through the `reply` / `stream_reply` family (which call
|
|
8
|
+
* `recordOutbound` explicitly). Everything else the gateway posts — the
|
|
9
|
+
* mid-turn activity card, the pinned status message, approval / boot / issues
|
|
10
|
+
* / worker-feed cards, `progress_update` lines, restart notices — consumed a
|
|
11
|
+
* real Telegram message id and left NO row behind. Measured on a live agent's
|
|
12
|
+
* buffer: 116 rows across a 266-id span above id 20000, i.e. ~56% of the ids
|
|
13
|
+
* in that chat were absent, clustered exactly where the cards were.
|
|
14
|
+
*
|
|
15
|
+
* That is user-visible, not cosmetic. The activity card is the most recent
|
|
16
|
+
* message on the operator's screen for most of a turn, so quote-replying to it
|
|
17
|
+
* is the natural gesture. Telegram then delivers `reply_to_message_id` pointing
|
|
18
|
+
* at a message the agent has no record of, the reply-antecedent resolver
|
|
19
|
+
* (`resolveReplyToFromBuffer`) gets `null` back, and the agent has to say "I
|
|
20
|
+
* can't see the message you replied to".
|
|
21
|
+
*
|
|
22
|
+
* The mechanism
|
|
23
|
+
* -------------
|
|
24
|
+
* Rather than add a `recordSystemOutbound(...)` call to each of the ~110 raw
|
|
25
|
+
* send sites (which is exactly the kind of per-call-site discipline that
|
|
26
|
+
* decays — the `reply` path was the only site anyone remembered), this hooks
|
|
27
|
+
* the ONE chokepoint every gateway outbound already goes through:
|
|
28
|
+
* `gateway.ts`'s `robustApiCall` (chat-lock → send-gate → retry policy). Every
|
|
29
|
+
* card send in the gateway is routed through it, enforced by the
|
|
30
|
+
* `check-bot-api-wrapping` lint guard.
|
|
31
|
+
*
|
|
32
|
+
* The observer reads the Telegram RESPONSE, which buys three things for free:
|
|
33
|
+
* - the real `message_id` (the only thing a reply can point at),
|
|
34
|
+
* - the chat and forum-topic the message actually landed in,
|
|
35
|
+
* - the BODY, as Telegram RENDERED it.
|
|
36
|
+
*
|
|
37
|
+
* The #4576 bug was reading only ONE body field off the response. Every card
|
|
38
|
+
* goes out via Bot API 10.1 `sendRichMessage`, and the `Message` it returns is
|
|
39
|
+
* a `Message.RichMessageMessage`: the body lives under `rich_message.blocks`
|
|
40
|
+
* ("Content of the message" — Bot API `RichMessage`), and `text` / `caption`
|
|
41
|
+
* are absent. `extractSentMessage` read `msg.text ?? msg.caption ?? ''` and so
|
|
42
|
+
* stored `''` on 100% of the rows on every agent in the fleet — the agent could
|
|
43
|
+
* see WHICH card was quote-replied to but never WHAT IT SAID. The fix is to
|
|
44
|
+
* read `rich_message` too, flattened by the same renderer the inbound
|
|
45
|
+
* rich-message handler uses.
|
|
46
|
+
*
|
|
47
|
+
* Preferring the response is not just simpler, it is more FAITHFUL. The
|
|
48
|
+
* request-side body for the dominant card path has already been through
|
|
49
|
+
* `richMessage()`'s `guardAccidentalFormatting` (`rich-send.ts`), which is
|
|
50
|
+
* applied in the CALLER, so `sent_text_capture.ts` is on the wire as
|
|
51
|
+
* `sent\_text\_capture.ts` and `$12.40` as `\$12.40`. The response's
|
|
52
|
+
* `rich_message` is the parsed, rendered block tree with those escapes already
|
|
53
|
+
* resolved — i.e. what the operator actually saw on screen, which is exactly
|
|
54
|
+
* what a quote-reply antecedent should say.
|
|
55
|
+
*
|
|
56
|
+
* The request-side stamp (`shared/sent-text-capture.ts`) is kept, LAST in the
|
|
57
|
+
* precedence order, as the fallback for the shapes a response cannot supply —
|
|
58
|
+
* a rich send whose blocks render to nothing (a media-only card), or a future
|
|
59
|
+
* verb whose response omits the body. It is never preferred over the response.
|
|
60
|
+
*
|
|
61
|
+
* Send vs edit is NOT guessed from the verb (verb tagging is not uniform
|
|
62
|
+
* across call sites and would rot). It falls out of the data: an edit returns
|
|
63
|
+
* the same `message_id` it edited, so the conditional insert no-ops and the
|
|
64
|
+
* call falls through to an in-place text refresh. One row per card, forever,
|
|
65
|
+
* regardless of how many times it is edited.
|
|
66
|
+
*
|
|
67
|
+
* Cost control. The activity card is the highest-volume repeated
|
|
68
|
+
* `editMessageText` in the gateway (it climbs every few seconds for the whole
|
|
69
|
+
* turn). Refreshing its stored text on every edit would be a SQLite write per
|
|
70
|
+
* edit. So a per-message throttle (`editRefreshMs`, default 20s) keeps the hot
|
|
71
|
+
* path entirely in memory: a card edit inside the window costs one Map lookup
|
|
72
|
+
* and no DB work at all. The stored text is therefore a recent snapshot, not
|
|
73
|
+
* a byte-exact mirror of the live card — which is the right trade for a
|
|
74
|
+
* quote-reply antecedent.
|
|
75
|
+
*
|
|
76
|
+
* Nothing here throws. A failure to record a card must never break the send it
|
|
77
|
+
* is observing.
|
|
78
|
+
*/
|
|
79
|
+
|
|
80
|
+
import { readSentText } from '../shared/sent-text-capture.js'
|
|
81
|
+
import { extractRichMessageText } from './rich-message-handler.js'
|
|
82
|
+
|
|
83
|
+
/** The subset of a Telegram `Message` response this observer reads. */
|
|
84
|
+
export interface SentMessageLike {
|
|
85
|
+
message_id?: unknown
|
|
86
|
+
chat?: { id?: unknown } | null
|
|
87
|
+
message_thread_id?: unknown
|
|
88
|
+
text?: unknown
|
|
89
|
+
caption?: unknown
|
|
90
|
+
rich_message?: unknown
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The subset of `robustApiCall`'s opts the observer reads. */
|
|
94
|
+
export interface ObservedCallOpts {
|
|
95
|
+
chat_id?: string
|
|
96
|
+
threadId?: number
|
|
97
|
+
verb?: string
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface SystemMessageObserverDeps {
|
|
101
|
+
/**
|
|
102
|
+
* `history.recordSystemOutbound`. Must return true iff it inserted a NEW
|
|
103
|
+
* row, and false if any row already existed for (chat_id, message_id).
|
|
104
|
+
*/
|
|
105
|
+
insert: (args: {
|
|
106
|
+
chat_id: string
|
|
107
|
+
thread_id: number | null
|
|
108
|
+
message_id: number
|
|
109
|
+
kind: string | null
|
|
110
|
+
text: string
|
|
111
|
+
}) => boolean
|
|
112
|
+
/**
|
|
113
|
+
* `history.updateSystemOutboundText`. Must return true iff it updated a row,
|
|
114
|
+
* and false when the target row is absent or is NOT a system row (i.e. the
|
|
115
|
+
* id belongs to a real reply or an inbound).
|
|
116
|
+
*/
|
|
117
|
+
updateText: (args: { chat_id: string; message_id: number; text: string }) => boolean
|
|
118
|
+
/** Injectable clock for the edit throttle. Defaults to `Date.now`. */
|
|
119
|
+
now?: () => number
|
|
120
|
+
/**
|
|
121
|
+
* Called when a card row is about to be written with an EMPTY body — i.e.
|
|
122
|
+
* neither the response nor the request-side stamp yielded anything readable.
|
|
123
|
+
*
|
|
124
|
+
* This is the alarm for the #4576 failure mode. That bug was silent for a
|
|
125
|
+
* whole release precisely because an empty body is indistinguishable from a
|
|
126
|
+
* healthy row unless someone queries `length(text)`. Fired at most ONCE per
|
|
127
|
+
* `kind` (else per raw verb) per process so a broken verb is loud in the log
|
|
128
|
+
* without becoming a per-send stderr storm. Never called with a non-empty
|
|
129
|
+
* body, and never for a response that is legitimately bodiless
|
|
130
|
+
* (`isLegitimatelyBodiless`) — an alarm on `sendSticker` is noise a reader
|
|
131
|
+
* cannot act on, and noise is what teaches people to ignore the alarm.
|
|
132
|
+
*
|
|
133
|
+
* Defaults to `defaultEmptyCardTextWarning` (stderr). Pass an explicit
|
|
134
|
+
* function to redirect it, or `() => {}` to silence it in a test.
|
|
135
|
+
*/
|
|
136
|
+
onEmptyText?: (info: { chat_id: string; message_id: number; kind: string | null }) => void
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface SystemMessageObserverOptions {
|
|
140
|
+
/**
|
|
141
|
+
* Minimum gap between two stored-text refreshes of the SAME message. Edits
|
|
142
|
+
* inside the window are dropped without touching SQLite.
|
|
143
|
+
*/
|
|
144
|
+
editRefreshMs?: number
|
|
145
|
+
/** Cap on tracked message ids before the oldest half is evicted. */
|
|
146
|
+
maxTracked?: number
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export const DEFAULT_EDIT_REFRESH_MS = 20_000
|
|
150
|
+
export const DEFAULT_MAX_TRACKED = 512
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Normalise a `robustApiCall` verb into the stored `kind` discriminator.
|
|
154
|
+
*
|
|
155
|
+
* The verb is the honest, already-present label for what a send IS
|
|
156
|
+
* (`activity-summary.send`, `boot-card`, `worker-feed`, `approval-card`), so
|
|
157
|
+
* the kind is derived rather than invented. The trailing transport suffix is
|
|
158
|
+
* stripped so a card's OPEN and its EDITs classify identically.
|
|
159
|
+
*
|
|
160
|
+
* Pure. Returns null for an absent / blank verb.
|
|
161
|
+
*/
|
|
162
|
+
export function normalizeSendVerb(verb: string | undefined | null): string | null {
|
|
163
|
+
if (typeof verb !== 'string') return null
|
|
164
|
+
const trimmed = verb.trim()
|
|
165
|
+
if (trimmed.length === 0) return null
|
|
166
|
+
const base = trimmed.replace(/\.(send|edit|create|post|update)$/i, '')
|
|
167
|
+
const cleaned = (base.length > 0 ? base : trimmed).slice(0, 64)
|
|
168
|
+
return cleaned.length > 0 ? cleaned : null
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Extract the (chat_id, message_id, thread, text) tuple from a Telegram API
|
|
173
|
+
* result, or null when the result is not a sent/edited Message (the retry
|
|
174
|
+
* wrapper also returns `true` for pins / deletes / reactions / callback
|
|
175
|
+
* answers, and `undefined` for a swallowed benign 400).
|
|
176
|
+
*
|
|
177
|
+
* The response's own `chat.id` wins over the caller's `chat_id` opt: it is what
|
|
178
|
+
* Telegram actually delivered to, and several call sites pass no `chat_id` at
|
|
179
|
+
* all.
|
|
180
|
+
*
|
|
181
|
+
* `text` is resolved in strict precedence order, most authoritative first:
|
|
182
|
+
* 1. `rich_message` on the RESPONSE, flattened by the same renderer the
|
|
183
|
+
* inbound rich-message handler uses. This is Telegram's own rendering of
|
|
184
|
+
* the block tree, so markdown escapes are already resolved — it is what
|
|
185
|
+
* the operator saw, and it is the shape every card send returns;
|
|
186
|
+
* 2. `text` / `caption`, the plain-send and media-caption response shapes;
|
|
187
|
+
* 3. the body stamped by `installSentTextCapture` from the outbound REQUEST —
|
|
188
|
+
* LAST, because on the dominant card path (`richMessage(body)`) it is the
|
|
189
|
+
* guard-escaped wire form, not the body as written. It is the fallback for
|
|
190
|
+
* responses that carry no renderable body at all.
|
|
191
|
+
* `''` only when all three are absent, which the observer treats as an alarm
|
|
192
|
+
* unless the response is legitimately bodiless. Pure.
|
|
193
|
+
*/
|
|
194
|
+
export function extractSentMessage(
|
|
195
|
+
result: unknown,
|
|
196
|
+
opts?: ObservedCallOpts,
|
|
197
|
+
): { chatId: string; messageId: number; threadId: number | null; text: string } | null {
|
|
198
|
+
if (result == null || typeof result !== 'object') return null
|
|
199
|
+
const msg = result as SentMessageLike
|
|
200
|
+
const id = msg.message_id
|
|
201
|
+
if (typeof id !== 'number' || !Number.isInteger(id) || id <= 0) return null
|
|
202
|
+
const rawChat = msg.chat?.id
|
|
203
|
+
const chatId =
|
|
204
|
+
rawChat != null && (typeof rawChat === 'number' || typeof rawChat === 'string')
|
|
205
|
+
? String(rawChat)
|
|
206
|
+
: opts?.chat_id
|
|
207
|
+
if (chatId == null || chatId.length === 0) return null
|
|
208
|
+
const rawThread = msg.message_thread_id
|
|
209
|
+
const threadId =
|
|
210
|
+
typeof rawThread === 'number' && Number.isInteger(rawThread)
|
|
211
|
+
? rawThread
|
|
212
|
+
: typeof opts?.threadId === 'number'
|
|
213
|
+
? opts.threadId
|
|
214
|
+
: null
|
|
215
|
+
const text =
|
|
216
|
+
(msg.rich_message != null ? extractRichMessageText(msg.rich_message) : undefined) ??
|
|
217
|
+
nonEmptyString(msg.text) ??
|
|
218
|
+
nonEmptyString(msg.caption) ??
|
|
219
|
+
readSentText(result) ??
|
|
220
|
+
''
|
|
221
|
+
return { chatId, messageId: id, threadId, text }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** `v` when it is a non-empty string, else undefined — so an empty `text` on
|
|
225
|
+
* the response falls THROUGH to the next precedence tier instead of pinning
|
|
226
|
+
* the result to `''`. */
|
|
227
|
+
function nonEmptyString(v: unknown): string | undefined {
|
|
228
|
+
return typeof v === 'string' && v.length > 0 ? v : undefined
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Response keys that mark a Telegram `Message` as LEGITIMATELY bodiless: the
|
|
233
|
+
* send verb that produced it has no user-visible text by construction.
|
|
234
|
+
*
|
|
235
|
+
* The empty-body alarm exists to make a recurrence of #4576 loud. It is only
|
|
236
|
+
* useful if it fires on a REGRESSION, so the verbs that are *supposed* to
|
|
237
|
+
* store an empty body — `sendSticker`, `sendAnimation`, `sendVoice`,
|
|
238
|
+
* `forwardMessage` of a media message, an uncaptioned `sendPhoto` — must not
|
|
239
|
+
* emit an alarm indistinguishable from one.
|
|
240
|
+
*
|
|
241
|
+
* Keyed on the RESPONSE SHAPE rather than on `opts.verb` deliberately: verb
|
|
242
|
+
* tagging is not uniform across call sites (`forwardMessage`,
|
|
243
|
+
* `gateway.ts`, passes none at all), so a verb allowlist would rot exactly
|
|
244
|
+
* where this needs to hold. A regressed CARD is a `rich_message` response
|
|
245
|
+
* whose blocks rendered to nothing — it carries none of these keys and still
|
|
246
|
+
* alarms. Pure.
|
|
247
|
+
*/
|
|
248
|
+
const BODILESS_MESSAGE_KEYS = [
|
|
249
|
+
'sticker', 'animation', 'voice', 'video_note', 'dice', 'game', 'poll',
|
|
250
|
+
'contact', 'location', 'venue', 'story', 'invoice', 'successful_payment',
|
|
251
|
+
'checklist', 'photo', 'video', 'audio', 'document', 'paid_media',
|
|
252
|
+
] as const
|
|
253
|
+
|
|
254
|
+
export function isLegitimatelyBodiless(result: unknown): boolean {
|
|
255
|
+
if (result == null || typeof result !== 'object') return false
|
|
256
|
+
const r = result as Record<string, unknown>
|
|
257
|
+
return BODILESS_MESSAGE_KEYS.some((k) => r[k] != null)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The observer's DEFAULT empty-body alarm: one stderr line naming the card kind
|
|
262
|
+
* whose text capture missed.
|
|
263
|
+
*
|
|
264
|
+
* It lives here, and is the default rather than something gateway.ts wires,
|
|
265
|
+
* for two reasons: gateway.ts is under an anti-inflation line ratchet
|
|
266
|
+
* (switchroom#2996) so new logic belongs in a module; and a caller that forgets
|
|
267
|
+
* to pass `onEmptyText` is exactly the caller that would re-ship #4576
|
|
268
|
+
* silently. Opting OUT is now the explicit act.
|
|
269
|
+
*/
|
|
270
|
+
export function defaultEmptyCardTextWarning(info: {
|
|
271
|
+
chat_id: string
|
|
272
|
+
message_id: number
|
|
273
|
+
kind: string | null
|
|
274
|
+
}): void {
|
|
275
|
+
try {
|
|
276
|
+
process.stderr.write(
|
|
277
|
+
`telegram gateway: card-history text capture MISSED kind=${info.kind ?? '-'} ` +
|
|
278
|
+
`chat=${info.chat_id} id=${info.message_id} — the response carried no ` +
|
|
279
|
+
`rich_message/text/caption and no request-side body was stamped, so a ` +
|
|
280
|
+
`quote-reply to this card will resolve its kind but not its body ` +
|
|
281
|
+
`(see gateway/system-message-observer.ts extractSentMessage)\n`,
|
|
282
|
+
)
|
|
283
|
+
} catch {
|
|
284
|
+
/* a broken stderr must never break the send */
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Per-id bookkeeping. `foreign` = the id belongs to a non-system row (a real
|
|
289
|
+
* reply or an inbound); never write to it again. `storedLen` is the length of
|
|
290
|
+
* the body currently in the row — 0 means the row is a HOLE, which the edit
|
|
291
|
+
* throttle must not preserve. */
|
|
292
|
+
type TrackedState = { lane: 'system' | 'foreign'; lastStoredMs: number; storedLen: number }
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Build the observer. The returned function is called with the RESOLVED result
|
|
296
|
+
* of every `robustApiCall` and never throws.
|
|
297
|
+
*/
|
|
298
|
+
export function makeSystemMessageObserver(
|
|
299
|
+
deps: SystemMessageObserverDeps,
|
|
300
|
+
options?: SystemMessageObserverOptions,
|
|
301
|
+
): (result: unknown, opts?: ObservedCallOpts) => void {
|
|
302
|
+
const now = deps.now ?? Date.now
|
|
303
|
+
const editRefreshMs = options?.editRefreshMs ?? DEFAULT_EDIT_REFRESH_MS
|
|
304
|
+
const maxTracked = Math.max(1, options?.maxTracked ?? DEFAULT_MAX_TRACKED)
|
|
305
|
+
const tracked = new Map<string, TrackedState>()
|
|
306
|
+
/** Kinds already reported through `onEmptyText` — one alarm per kind, per process. */
|
|
307
|
+
const emptyReported = new Set<string>()
|
|
308
|
+
const onEmptyText = deps.onEmptyText ?? defaultEmptyCardTextWarning
|
|
309
|
+
|
|
310
|
+
function reportEmpty(
|
|
311
|
+
chatId: string,
|
|
312
|
+
messageId: number,
|
|
313
|
+
kind: string | null,
|
|
314
|
+
verb: string | undefined,
|
|
315
|
+
): void {
|
|
316
|
+
// Bucket on the kind, else the RAW verb, else the untagged catch-all. Using
|
|
317
|
+
// `kind ?? '<untagged>'` alone collapsed every untagged verb into one
|
|
318
|
+
// bucket, so the first bodiless untagged send in the process permanently
|
|
319
|
+
// silenced the alarm for every other untagged verb — including a real
|
|
320
|
+
// regression.
|
|
321
|
+
const bucket = kind ?? (typeof verb === 'string' && verb.length > 0 ? verb : '<untagged>')
|
|
322
|
+
if (emptyReported.has(bucket)) return
|
|
323
|
+
emptyReported.add(bucket)
|
|
324
|
+
try {
|
|
325
|
+
onEmptyText({ chat_id: chatId, message_id: messageId, kind })
|
|
326
|
+
} catch {
|
|
327
|
+
/* the alarm must never break the send either */
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function remember(key: string, state: TrackedState): void {
|
|
332
|
+
tracked.set(key, state)
|
|
333
|
+
if (tracked.size > maxTracked) {
|
|
334
|
+
// Map iterates in insertion order — drop the oldest half in one pass so
|
|
335
|
+
// eviction is amortised O(1) rather than per-insert.
|
|
336
|
+
const drop = Math.ceil(tracked.size / 2)
|
|
337
|
+
let i = 0
|
|
338
|
+
for (const k of tracked.keys()) {
|
|
339
|
+
if (i++ >= drop) break
|
|
340
|
+
tracked.delete(k)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return function observeSentMessage(result: unknown, opts?: ObservedCallOpts): void {
|
|
346
|
+
try {
|
|
347
|
+
const sent = extractSentMessage(result, opts)
|
|
348
|
+
if (sent == null) return
|
|
349
|
+
const key = `${sent.chatId}:${sent.messageId}`
|
|
350
|
+
const t = now()
|
|
351
|
+
const seen = tracked.get(key)
|
|
352
|
+
|
|
353
|
+
const kind = normalizeSendVerb(opts?.verb)
|
|
354
|
+
if (sent.text.length === 0 && !isLegitimatelyBodiless(result)) {
|
|
355
|
+
reportEmpty(sent.chatId, sent.messageId, kind, opts?.verb)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (seen != null) {
|
|
359
|
+
if (seen.lane === 'foreign') return
|
|
360
|
+
// Never blank a body we already stored. A refresh whose text did not
|
|
361
|
+
// reach us is missing information, not new information — overwriting
|
|
362
|
+
// with `''` would destroy a usable quote-reply antecedent and hand the
|
|
363
|
+
// agent the #4576 symptom on a row that was healthy a moment ago.
|
|
364
|
+
if (sent.text.length === 0) return
|
|
365
|
+
// The dual rule, and the one whose absence kept the #4576 symptom alive
|
|
366
|
+
// on a row the alarm had already given up on: ALWAYS fill an EMPTY
|
|
367
|
+
// stored body. A bodiless first observation (a media-only card, a
|
|
368
|
+
// response we could not read) inserts `''` and starts the throttle
|
|
369
|
+
// clock; the first REAL body then lands inside the 20s window and was
|
|
370
|
+
// dropped, leaving the row permanently unusable as a quote-reply
|
|
371
|
+
// antecedent. The throttle exists to cap SQLite writes on a card that
|
|
372
|
+
// is already READABLE, so it only applies once something is stored.
|
|
373
|
+
if (seen.storedLen > 0 && t - seen.lastStoredMs < editRefreshMs) return
|
|
374
|
+
if (deps.updateText({ chat_id: sent.chatId, message_id: sent.messageId, text: sent.text })) {
|
|
375
|
+
seen.lastStoredMs = t
|
|
376
|
+
seen.storedLen = sent.text.length
|
|
377
|
+
} else {
|
|
378
|
+
// The row is gone (retention prune / delete) or was promoted to a
|
|
379
|
+
// real `assistant` reply by recordOutbound. Either way this id is no
|
|
380
|
+
// longer ours to write.
|
|
381
|
+
seen.lane = 'foreign'
|
|
382
|
+
}
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const inserted = deps.insert({
|
|
387
|
+
chat_id: sent.chatId,
|
|
388
|
+
thread_id: sent.threadId,
|
|
389
|
+
message_id: sent.messageId,
|
|
390
|
+
kind,
|
|
391
|
+
text: sent.text,
|
|
392
|
+
})
|
|
393
|
+
if (inserted) {
|
|
394
|
+
remember(key, { lane: 'system', lastStoredMs: t, storedLen: sent.text.length })
|
|
395
|
+
return
|
|
396
|
+
}
|
|
397
|
+
// A row already exists for this id and we did not create it in this
|
|
398
|
+
// process: either a real reply / inbound (leave it alone), or a card this
|
|
399
|
+
// gateway posted before a restart. One probing update disambiguates —
|
|
400
|
+
// `updateText` only ever matches a `system` row. The probe WRITES, so an
|
|
401
|
+
// empty body cannot be used to run it: skip, stay untracked, and let the
|
|
402
|
+
// next observation of this id (which carries a body) do the probing.
|
|
403
|
+
if (sent.text.length === 0) return
|
|
404
|
+
const refreshed = deps.updateText({
|
|
405
|
+
chat_id: sent.chatId,
|
|
406
|
+
message_id: sent.messageId,
|
|
407
|
+
text: sent.text,
|
|
408
|
+
})
|
|
409
|
+
remember(key, {
|
|
410
|
+
lane: refreshed ? 'system' : 'foreign',
|
|
411
|
+
lastStoredMs: t,
|
|
412
|
+
storedLen: refreshed ? sent.text.length : 0,
|
|
413
|
+
})
|
|
414
|
+
} catch {
|
|
415
|
+
/* observing a send must never break the send */
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
@@ -38,15 +38,14 @@
|
|
|
38
38
|
import {
|
|
39
39
|
closeSync,
|
|
40
40
|
existsSync,
|
|
41
|
-
mkdirSync,
|
|
42
41
|
openSync,
|
|
43
42
|
readFileSync,
|
|
44
43
|
statSync,
|
|
45
44
|
unlinkSync,
|
|
46
45
|
utimesSync,
|
|
47
|
-
writeFileSync,
|
|
48
46
|
} from "node:fs";
|
|
49
47
|
import { join } from "node:path";
|
|
48
|
+
import { mkdirStateSync, writeStateFileSync } from "../../src/util/state-owner.js";
|
|
50
49
|
|
|
51
50
|
export const TURN_ACTIVE_MARKER_FILE = "turn-active.json";
|
|
52
51
|
|
|
@@ -82,8 +81,8 @@ export interface TurnActiveMarker {
|
|
|
82
81
|
*/
|
|
83
82
|
export function writeTurnActiveMarker(stateDir: string, marker: TurnActiveMarker): void {
|
|
84
83
|
try {
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
mkdirStateSync(stateDir, { recursive: true });
|
|
85
|
+
writeStateFileSync(
|
|
87
86
|
join(stateDir, TURN_ACTIVE_MARKER_FILE),
|
|
88
87
|
JSON.stringify(marker, null, 2) + "\n",
|
|
89
88
|
{ mode: 0o600 },
|