switchroom 0.18.28 → 0.18.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/handoff-briefing.sh +15 -2
- package/dist/agent-scheduler/index.js +111 -7
- package/dist/auth-broker/index.js +154 -73
- package/dist/cli/autoaccept-poll.js +8 -3
- package/dist/cli/drive-write-pretool.mjs +8 -3
- package/dist/cli/ms-365-write-pretool.mjs +158 -11
- package/dist/cli/notion-write-pretool.mjs +103 -4
- package/dist/cli/switchroom.js +2712 -2219
- package/dist/host-control/main.js +110 -70
- package/dist/vault/approvals/kernel-server.js +116 -70
- package/dist/vault/broker/server.js +314 -202
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +105 -34
- package/telegram-plugin/dist/bridge/bridge.js +71 -47
- package/telegram-plugin/dist/gateway/gateway.js +1128 -666
- package/telegram-plugin/dist/server.js +89 -64
- package/telegram-plugin/gateway/backstop-delivery.ts +272 -0
- package/telegram-plugin/gateway/forward-origin.ts +9 -1
- package/telegram-plugin/gateway/gateway.ts +656 -388
- package/telegram-plugin/gateway/model-command.ts +331 -602
- package/telegram-plugin/gateway/session-model-file.ts +40 -0
- package/telegram-plugin/gateway/turn-record-status.ts +45 -0
- package/telegram-plugin/gateway/unhandled-message.ts +177 -0
- package/telegram-plugin/history.ts +153 -23
- package/telegram-plugin/llm-error-present.ts +24 -0
- package/telegram-plugin/model-unavailable.ts +55 -0
- package/telegram-plugin/operator-events.ts +113 -0
- package/telegram-plugin/pending-user-notice.ts +88 -0
- package/telegram-plugin/shared/local-time.ts +99 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +250 -0
- package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +103 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +264 -0
- package/telegram-plugin/tests/forward-origin.test.ts +30 -3
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +111 -60
- package/telegram-plugin/tests/history.test.ts +88 -0
- package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +278 -0
- package/telegram-plugin/tests/local-time.test.ts +135 -0
- package/telegram-plugin/tests/model-command.test.ts +427 -1512
- package/telegram-plugin/tests/session-model-file.test.ts +23 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +34 -0
- package/telegram-plugin/tier-downgrade.ts +4 -3
- package/telegram-plugin/turn-flush-safety.ts +25 -1
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +399 -2
- package/vendor/hindsight-memory/scripts/lib/client.py +47 -0
- package/vendor/hindsight-memory/scripts/lib/content.py +93 -7
- package/vendor/hindsight-memory/scripts/lib/turnlog.py +450 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +467 -0
- package/vendor/hindsight-memory/tests/test_content.py +63 -7
|
@@ -38,6 +38,15 @@ import { isValidModelArg } from './model-command.js'
|
|
|
38
38
|
|
|
39
39
|
export const SESSION_MODEL_FILE = '.session-model'
|
|
40
40
|
export const CONFIGURED_DEFAULT_MODEL_FILE = '.configured-default-model'
|
|
41
|
+
/**
|
|
42
|
+
* Bounded-retry attempt counter for the session-model carrier (#3284).
|
|
43
|
+
* start.sh increments this on every boot that reads `.session-model` and
|
|
44
|
+
* applies it, and gives up (reverts + alerts) once it exceeds its bound. The
|
|
45
|
+
* gateway clears BOTH this file and the carrier on a healthy boot (see
|
|
46
|
+
* consumeSessionModelCarrierOnHealthyBoot) — the "healthy" signal a wedged
|
|
47
|
+
* boot cannot fake. Kept in sync with start.sh.hbs.
|
|
48
|
+
*/
|
|
49
|
+
export const SESSION_MODEL_BOOT_ATTEMPTS_FILE = '.session-model-boot-attempts'
|
|
41
50
|
|
|
42
51
|
export interface SessionModelRecord {
|
|
43
52
|
model: string
|
|
@@ -125,6 +134,37 @@ export function clearSessionModelFile(agentDir: string): void {
|
|
|
125
134
|
}
|
|
126
135
|
}
|
|
127
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Consume the session-model carrier on a HEALTHY boot (#3284).
|
|
139
|
+
*
|
|
140
|
+
* Called once this gateway has ACQUIRED the boot lock (boot.lock_acquired) —
|
|
141
|
+
* the deterministic signal that THIS boot is the surviving healthy session,
|
|
142
|
+
* which a boot that wedges before lock-acquire cannot fake. Deletes BOTH the
|
|
143
|
+
* consume-once carrier and the bounded-retry attempt counter, so:
|
|
144
|
+
* - the next ordinary restart (deploy, /restart, crash) finds no carrier and
|
|
145
|
+
* reverts to the configured default (session-scoped semantics preserved);
|
|
146
|
+
* - a transient wedge BEFORE this point leaves the carrier in place, so the
|
|
147
|
+
* retry boot re-applies the intended model instead of silently reverting.
|
|
148
|
+
*
|
|
149
|
+
* This is what moved out of start.sh's old delete-before-apply: start.sh no
|
|
150
|
+
* longer consumes the carrier itself, it only APPLIES it and lets this healthy
|
|
151
|
+
* signal do the consume. Best-effort — a failure here just means the carrier is
|
|
152
|
+
* re-read (and re-applied, harmlessly) on the next boot, still bounded by the
|
|
153
|
+
* counter. Idempotent.
|
|
154
|
+
*/
|
|
155
|
+
export function consumeSessionModelCarrierOnHealthyBoot(agentDir: string): void {
|
|
156
|
+
try {
|
|
157
|
+
rmSync(join(agentDir, SESSION_MODEL_FILE), { force: true })
|
|
158
|
+
} catch {
|
|
159
|
+
/* best-effort */
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
rmSync(join(agentDir, SESSION_MODEL_BOOT_ATTEMPTS_FILE), { force: true })
|
|
163
|
+
} catch {
|
|
164
|
+
/* best-effort */
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
128
168
|
/** Restore a rollback snapshot taken with readSessionModelFileRaw. */
|
|
129
169
|
export function restoreSessionModelFileRaw(agentDir: string, raw: string | null): void {
|
|
130
170
|
if (raw == null) {
|
|
@@ -79,6 +79,51 @@ export function backstopSendOutcome(args: {
|
|
|
79
79
|
return 'delivered'
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Resolve a backstop send's outcome using the #3276 RECEIPT gate: success
|
|
84
|
+
* requires at least one FRESH, non-card chat message id. This supersedes the
|
|
85
|
+
* naive chunk-count comparison for the turn-flush backstop, where a delivery
|
|
86
|
+
* that landed only onto the (soon-swept) progress card must count as a failure,
|
|
87
|
+
* not `complete`.
|
|
88
|
+
*
|
|
89
|
+
* threw → failed
|
|
90
|
+
* no fresh non-card id delivered → failed (card-only "success")
|
|
91
|
+
* fewer fresh ids than chunks split → failed (partial delivery)
|
|
92
|
+
* >=1 fresh id AND all chunks landed → delivered
|
|
93
|
+
*
|
|
94
|
+
* `cardMessageId` is the taken-over progress-card id (or null); any id equal to
|
|
95
|
+
* it is excluded from the delivered set before counting.
|
|
96
|
+
*/
|
|
97
|
+
export function backstopSendOutcomeGated(args: {
|
|
98
|
+
threw: boolean
|
|
99
|
+
sentIds: readonly number[]
|
|
100
|
+
chunkCount: number
|
|
101
|
+
cardMessageId: number | null
|
|
102
|
+
}): DeliveryOutcome {
|
|
103
|
+
if (args.threw) return 'failed'
|
|
104
|
+
if (args.chunkCount === 0) return 'failed'
|
|
105
|
+
const freshCount = args.sentIds.filter(
|
|
106
|
+
id => args.cardMessageId == null || id !== args.cardMessageId,
|
|
107
|
+
).length
|
|
108
|
+
// Guard 7: a card-only delivery (zero fresh ids) is a hard failure.
|
|
109
|
+
if (freshCount === 0) return 'failed'
|
|
110
|
+
if (freshCount < args.chunkCount) return 'failed'
|
|
111
|
+
return 'delivered'
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Stamp a turn's `deliveryOutcome` from a resolved backstop send using the
|
|
116
|
+
* #3276 receipt gate (fresh non-card ids only). Mutates and returns the outcome.
|
|
117
|
+
*/
|
|
118
|
+
export function finalizeBackstopSendGated(
|
|
119
|
+
turn: { deliveryOutcome?: DeliveryOutcome },
|
|
120
|
+
send: { threw: boolean; sentIds: readonly number[]; chunkCount: number; cardMessageId: number | null },
|
|
121
|
+
): DeliveryOutcome {
|
|
122
|
+
const outcome = backstopSendOutcomeGated(send)
|
|
123
|
+
turn.deliveryOutcome = outcome
|
|
124
|
+
return outcome
|
|
125
|
+
}
|
|
126
|
+
|
|
82
127
|
/**
|
|
83
128
|
* Stamp a turn's `deliveryOutcome` from a resolved backstop send. This is the
|
|
84
129
|
* exact accounting the turn-flush IIFE's `finally` performs — factored here so
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal catch-all + diagnostic tap for inbound Telegram messages (#3300).
|
|
3
|
+
*
|
|
4
|
+
* THE CLASS OF BUG THIS CLOSES: the gateway registers only content-specific
|
|
5
|
+
* handlers (`bot.on('message:text')`, `:photo`, … `:paid_media`). grammy
|
|
6
|
+
* ^1.44 routes `bot.on` as filtering middleware — internally
|
|
7
|
+
* `on → filter(pred, handler) → branch(pred, handler, pass)` — and a
|
|
8
|
+
* `message` update matching NONE of the registered predicates falls through
|
|
9
|
+
* every branch and is SILENTLY discarded: no log, no ack, no history row.
|
|
10
|
+
*
|
|
11
|
+
* Live signature (2026-07-16, klanker DM): message_id 19090 was allocated
|
|
12
|
+
* between an outbound reply (19089, 21:51:11Z) and the next inbound (19091,
|
|
13
|
+
* 21:59:31Z) with ZERO gateway trace — no early_ack, no gw-trace inbound, no
|
|
14
|
+
* gate-deny, no error — while polling stayed healthy on both sides. Exactly
|
|
15
|
+
* the zero-observability failure this module makes impossible: every update
|
|
16
|
+
* is now logged on receipt (tap) and every message either produces a turn or
|
|
17
|
+
* an explicit log-only line (catch-all).
|
|
18
|
+
*
|
|
19
|
+
* Extracted into its own module (rather than inline in gateway.ts) so tests
|
|
20
|
+
* can drive the REAL registration path on a real grammy Bot — gateway.ts is
|
|
21
|
+
* a side-effecting module that cannot be imported into a unit test.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { Bot, Context } from 'grammy'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Top-level `message` envelope fields — identity/routing metadata, excluded
|
|
28
|
+
* when surfacing the CONTENT keys of a message (the fields that determine
|
|
29
|
+
* which `bot.on('message:*')` handler should match).
|
|
30
|
+
*/
|
|
31
|
+
export const MESSAGE_ENVELOPE_KEYS: ReadonlySet<string> = new Set<string>([
|
|
32
|
+
'message_id', 'message_thread_id', 'date', 'chat', 'from', 'sender_chat',
|
|
33
|
+
'forward_origin', 'reply_to_message', 'external_reply', 'quote',
|
|
34
|
+
'reply_to_story', 'edit_date', 'media_group_id', 'author_signature',
|
|
35
|
+
'is_topic_message', 'is_automatic_forward', 'via_bot', 'sender_boost_count',
|
|
36
|
+
'business_connection_id', 'effect_id', 'has_protected_content',
|
|
37
|
+
'is_from_offline', 'link_preview_options', 'show_caption_above_media',
|
|
38
|
+
'entities', 'caption_entities', 'paid_star_count',
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Known-noise SERVICE message content keys: chat/topic lifecycle events that
|
|
43
|
+
* carry no user intent for the agent. These are LOGGED (never silently
|
|
44
|
+
* dropped — the whole point of #3300) but do NOT become agent turns:
|
|
45
|
+
* forum-topic lifecycle events are a recurring spam vector in supergroups,
|
|
46
|
+
* and each spurious turn costs tokens and attention.
|
|
47
|
+
*
|
|
48
|
+
* Anything NOT in this set that reaches the catch-all is delivered as a turn
|
|
49
|
+
* (fail-toward-delivery: an unknown content type plausibly carries user
|
|
50
|
+
* intent; better a placeholder turn than a lost message).
|
|
51
|
+
*/
|
|
52
|
+
export const SERVICE_NOISE_KEYS: ReadonlySet<string> = new Set<string>([
|
|
53
|
+
'new_chat_members', 'left_chat_member', 'new_chat_title', 'new_chat_photo',
|
|
54
|
+
'delete_chat_photo', 'group_chat_created', 'supergroup_chat_created',
|
|
55
|
+
'channel_chat_created', 'message_auto_delete_timer_changed',
|
|
56
|
+
'migrate_to_chat_id', 'migrate_from_chat_id',
|
|
57
|
+
'forum_topic_created', 'forum_topic_edited', 'forum_topic_closed',
|
|
58
|
+
'forum_topic_reopened', 'general_forum_topic_hidden',
|
|
59
|
+
'general_forum_topic_unhidden',
|
|
60
|
+
'video_chat_scheduled', 'video_chat_started', 'video_chat_ended',
|
|
61
|
+
'video_chat_participants_invited',
|
|
62
|
+
'giveaway_created', 'giveaway', 'giveaway_winners', 'giveaway_completed',
|
|
63
|
+
'boost_added', 'chat_background_set', 'write_access_allowed',
|
|
64
|
+
'proximity_alert_triggered', 'chat_set_theme',
|
|
65
|
+
'connected_website', 'direct_message_price_changed',
|
|
66
|
+
])
|
|
67
|
+
|
|
68
|
+
/** The CONTENT keys of a message — top-level keys minus envelope metadata. */
|
|
69
|
+
export function messageContentKeys(msg: Record<string, unknown>): string[] {
|
|
70
|
+
return Object.keys(msg).filter(k => !MESSAGE_ENVELOPE_KEYS.has(k))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type UnhandledMessagePlan =
|
|
74
|
+
| { action: 'turn'; text: string; contentKeys: string[] }
|
|
75
|
+
| { action: 'log-only'; contentKeys: string[] }
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Decide what the catch-all does with a message no specific handler consumed:
|
|
79
|
+
* known-noise service messages → log-only (no turn); everything else → a turn
|
|
80
|
+
* with best-effort text (`text ?? caption ?? placeholder naming the type`).
|
|
81
|
+
*/
|
|
82
|
+
export function planUnhandledMessage(msg: Record<string, unknown>): UnhandledMessagePlan {
|
|
83
|
+
const contentKeys = messageContentKeys(msg)
|
|
84
|
+
if (contentKeys.length > 0 && contentKeys.every(k => SERVICE_NOISE_KEYS.has(k))) {
|
|
85
|
+
return { action: 'log-only', contentKeys }
|
|
86
|
+
}
|
|
87
|
+
const contentType = contentKeys[0] ?? 'unknown'
|
|
88
|
+
const text =
|
|
89
|
+
(typeof msg.text === 'string' ? msg.text : undefined) ??
|
|
90
|
+
(typeof msg.caption === 'string' ? msg.caption : undefined) ??
|
|
91
|
+
`(unhandled message content: ${contentType})`
|
|
92
|
+
return { action: 'turn', text, contentKeys }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** One compact diagnostic line per update; cap prevents log flooding. */
|
|
96
|
+
export const TAP_MAX_LINES_PER_MINUTE = 300
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Install the diagnostic update tap: pass-through middleware (`bot.use` →
|
|
100
|
+
* always calls next()) logging every received update's type + content keys
|
|
101
|
+
* (never payload bodies — privacy). Rate-limited to TAP_MAX_LINES_PER_MINUTE
|
|
102
|
+
* per wall-clock minute; on rollover a single summary line reports how many
|
|
103
|
+
* were suppressed, so even a flood is never invisible.
|
|
104
|
+
*
|
|
105
|
+
* MUST be installed before all `bot.on` handlers so it observes every update.
|
|
106
|
+
*/
|
|
107
|
+
export function installUpdateTap(
|
|
108
|
+
bot: Pick<Bot, 'use'>,
|
|
109
|
+
log: (line: string) => void,
|
|
110
|
+
nowMs: () => number = Date.now,
|
|
111
|
+
): void {
|
|
112
|
+
let windowStart = 0
|
|
113
|
+
let windowCount = 0
|
|
114
|
+
let suppressed = 0
|
|
115
|
+
bot.use(async (ctx, next) => {
|
|
116
|
+
try {
|
|
117
|
+
const now = nowMs()
|
|
118
|
+
if (now - windowStart >= 60_000) {
|
|
119
|
+
if (suppressed > 0) {
|
|
120
|
+
log(`telegram gateway: rx tap suppressed ${suppressed} update lines in the last minute (cap ${TAP_MAX_LINES_PER_MINUTE}/min)\n`)
|
|
121
|
+
}
|
|
122
|
+
windowStart = now
|
|
123
|
+
windowCount = 0
|
|
124
|
+
suppressed = 0
|
|
125
|
+
}
|
|
126
|
+
if (windowCount < TAP_MAX_LINES_PER_MINUTE) {
|
|
127
|
+
windowCount++
|
|
128
|
+
const upd = ctx.update as unknown as Record<string, unknown>
|
|
129
|
+
const updateType = Object.keys(upd).find(k => k !== 'update_id') ?? 'unknown'
|
|
130
|
+
const msg = ctx.message as unknown as Record<string, unknown> | undefined
|
|
131
|
+
const detail = msg ? ` content=[${messageContentKeys(msg).join(',')}]` : ''
|
|
132
|
+
log(`telegram gateway: rx update_id=${ctx.update.update_id} type=${updateType}${detail}\n`)
|
|
133
|
+
} else {
|
|
134
|
+
suppressed++
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
// The diagnostic tap must never break the inbound pipeline.
|
|
138
|
+
}
|
|
139
|
+
await next()
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Install the terminal catch-all. MUST be registered LAST among the
|
|
145
|
+
* `message`/`message:*` handlers: grammy leaf handlers never call `next()`,
|
|
146
|
+
* so a matched specific handler stops the chain (specific handler always
|
|
147
|
+
* wins) and this only fires for messages no specific handler consumed —
|
|
148
|
+
* registration order IS the no-double-handling guarantee.
|
|
149
|
+
*
|
|
150
|
+
* `onInbound` is the normal inbound pipeline (gateway.ts passes
|
|
151
|
+
* handleInboundCoalesced), which applies the same access gating and
|
|
152
|
+
* forward-origin parsing as every other content handler.
|
|
153
|
+
*/
|
|
154
|
+
export function installUnhandledMessageCatchAll(
|
|
155
|
+
bot: Pick<Bot, 'on'>,
|
|
156
|
+
onInbound: (ctx: Context, text: string) => Promise<void>,
|
|
157
|
+
log: (line: string) => void,
|
|
158
|
+
): void {
|
|
159
|
+
bot.on('message', async ctx => {
|
|
160
|
+
try {
|
|
161
|
+
const msg = ctx.message as unknown as Record<string, unknown>
|
|
162
|
+
const plan = planUnhandledMessage(msg)
|
|
163
|
+
// Log KEYS + ids only — never the payload bodies (privacy).
|
|
164
|
+
log(
|
|
165
|
+
`telegram gateway: catch-all inbound (no specific handler) ` +
|
|
166
|
+
`update_id=${ctx.update.update_id} chat_id=${ctx.chat?.id ?? '?'} ` +
|
|
167
|
+
`message_id=${ctx.message?.message_id ?? '?'} ` +
|
|
168
|
+
`content_keys=[${plan.contentKeys.join(',')}] action=${plan.action}\n`,
|
|
169
|
+
)
|
|
170
|
+
if (plan.action === 'turn') {
|
|
171
|
+
await onInbound(ctx, plan.text)
|
|
172
|
+
}
|
|
173
|
+
} catch (err) {
|
|
174
|
+
log(`telegram gateway: catch-all handler error: ${(err as Error).message}\n`)
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
}
|
|
@@ -142,6 +142,40 @@ const MAX_LIMIT = 50
|
|
|
142
142
|
let db: SqliteDatabase | null = null
|
|
143
143
|
let dbPath: string | null = null
|
|
144
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Loud, unconditional failure logging for the history writer.
|
|
147
|
+
*
|
|
148
|
+
* Recording bugs are silent by construction: every gateway call site wraps
|
|
149
|
+
* `recordOutbound` / `recordInbound` in a `try { … } catch {}` (or a catch that
|
|
150
|
+
* logs a caller-shaped message), and several of those catches are EMPTY. When a
|
|
151
|
+
* write throws or drops a row, the caller's empty catch hides it — the exact
|
|
152
|
+
* failure mode behind the 2026-07-16 incident (turn-flush deliveries 18944 /
|
|
153
|
+
* 18958 delivered to Telegram but absent from history.db, blinding
|
|
154
|
+
* `getRecentOutboundCount` / `hasOutboundDeliveredSince`). We therefore log HERE,
|
|
155
|
+
* inside the writer, BEFORE any throw — so even a caller's `catch {}` cannot
|
|
156
|
+
* suppress the diagnostic. Deterministic surfacing, not prompt discipline.
|
|
157
|
+
*/
|
|
158
|
+
function warnHistory(msg: string): void {
|
|
159
|
+
try {
|
|
160
|
+
process.stderr.write(`telegram history: ${msg}\n`)
|
|
161
|
+
} catch {
|
|
162
|
+
/* stderr write must never itself break the record path */
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A Telegram message_id is a positive 32-bit-ish integer. A null / undefined /
|
|
168
|
+
* NaN / non-integer id can only arrive from a malformed send result (e.g. an
|
|
169
|
+
* API wrapper that resolved without a real message object). Inserting it either
|
|
170
|
+
* violates the NOT NULL PRIMARY KEY (throws → swallowed by an empty caller
|
|
171
|
+
* catch) or silently corrupts the key space. We filter such ids out and log
|
|
172
|
+
* loudly instead — a delivered-but-unrecorded row is a durability defect the
|
|
173
|
+
* operator must see, not a silent drop.
|
|
174
|
+
*/
|
|
175
|
+
function isValidMessageId(id: unknown): id is number {
|
|
176
|
+
return typeof id === 'number' && Number.isInteger(id) && id > 0
|
|
177
|
+
}
|
|
178
|
+
|
|
145
179
|
/**
|
|
146
180
|
* Open (or create) the history DB and run migrations + retention sweep.
|
|
147
181
|
* Idempotent — safe to call once at server startup.
|
|
@@ -271,6 +305,72 @@ export function initHistory(stateDir: string, retentionDays = 30): void {
|
|
|
271
305
|
const cutoff = Math.floor(Date.now() / 1000) - retentionDays * 86400
|
|
272
306
|
db.prepare('DELETE FROM messages WHERE ts < ?').run(cutoff)
|
|
273
307
|
}
|
|
308
|
+
|
|
309
|
+
// Boot-time writer self-check (2026-07-16 incident hardening). "history
|
|
310
|
+
// capture enabled" was logged at every boot — including the 02:17 and 04:58
|
|
311
|
+
// restarts around the incident — yet a whole class of deliveries never
|
|
312
|
+
// reached the DB. A successful `new Database(...)` + schema DDL does NOT prove
|
|
313
|
+
// the row-insert path is functional (a read-only mount, a full disk, an
|
|
314
|
+
// orphaned inode after an in-place dir replace, or a corrupt page all pass DDL
|
|
315
|
+
// but fail INSERT). So we prove it with a real INSERT + SELECT + DELETE
|
|
316
|
+
// round-trip on a sentinel row, and log LOUDLY if it fails. This turns a
|
|
317
|
+
// silent, hours-later-discovered writer outage into a deterministic boot
|
|
318
|
+
// signal the operator can see immediately.
|
|
319
|
+
const check = verifyHistoryWritable()
|
|
320
|
+
if (!check.ok) {
|
|
321
|
+
warnHistory(
|
|
322
|
+
`WRITER SELF-CHECK FAILED at boot (path=${path}): ${check.error ?? 'unknown'} — ` +
|
|
323
|
+
`history recording is NOT durable; get_recent_messages recovery and the ` +
|
|
324
|
+
`reply-backstop already-replied suppression will be blind. Investigate the ` +
|
|
325
|
+
`DB path/mount/permissions before trusting delivery accounting.`,
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Prove the history writer's INSERT path actually works, not just that the DB
|
|
332
|
+
* opened and the schema DDL ran. Performs a real INSERT + SELECT + DELETE of a
|
|
333
|
+
* sentinel row keyed on a reserved chat_id that no live chat can collide with,
|
|
334
|
+
* and cleans it up unconditionally. Returns `{ ok:false, error }` (never throws)
|
|
335
|
+
* so the boot path and an operator self-check tool can both call it safely.
|
|
336
|
+
*
|
|
337
|
+
* This is the deterministic mechanism the 2026-07-16 incident lacked: a
|
|
338
|
+
* writer that opens fine but cannot persist rows (read-only mount, full disk,
|
|
339
|
+
* orphaned inode, corruption) is caught HERE at boot instead of being inferred
|
|
340
|
+
* hours later from missing rows.
|
|
341
|
+
*
|
|
342
|
+
* No-op safe: returns `{ ok:false }` with an explanatory error if
|
|
343
|
+
* `initHistory` was never called.
|
|
344
|
+
*/
|
|
345
|
+
export function verifyHistoryWritable(): { ok: boolean; error?: string } {
|
|
346
|
+
if (db == null) return { ok: false, error: 'initHistory() not called' }
|
|
347
|
+
const SENTINEL_CHAT = '__history_selfcheck__'
|
|
348
|
+
const sentinelId = Date.now()
|
|
349
|
+
try {
|
|
350
|
+
// Clear any stale sentinel from a prior crashed self-check first.
|
|
351
|
+
db.prepare('DELETE FROM messages WHERE chat_id = ?').run(SENTINEL_CHAT)
|
|
352
|
+
db.prepare(
|
|
353
|
+
`INSERT OR REPLACE INTO messages
|
|
354
|
+
(chat_id, thread_id, message_id, role, ts, text)
|
|
355
|
+
VALUES (?, NULL, ?, 'assistant', ?, ?)`,
|
|
356
|
+
).run(SENTINEL_CHAT, sentinelId, Math.floor(Date.now() / 1000), 'selfcheck')
|
|
357
|
+
const row = db
|
|
358
|
+
.prepare('SELECT text FROM messages WHERE chat_id = ? AND message_id = ?')
|
|
359
|
+
.get(SENTINEL_CHAT, sentinelId) as { text?: string } | undefined
|
|
360
|
+
if (row?.text !== 'selfcheck') {
|
|
361
|
+
return { ok: false, error: 'sentinel row not read back after insert' }
|
|
362
|
+
}
|
|
363
|
+
return { ok: true }
|
|
364
|
+
} catch (err) {
|
|
365
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) }
|
|
366
|
+
} finally {
|
|
367
|
+
// Never leave the sentinel behind, even if the SELECT/assert path threw.
|
|
368
|
+
try {
|
|
369
|
+
db.prepare('DELETE FROM messages WHERE chat_id = ?').run(SENTINEL_CHAT)
|
|
370
|
+
} catch {
|
|
371
|
+
/* best-effort cleanup */
|
|
372
|
+
}
|
|
373
|
+
}
|
|
274
374
|
}
|
|
275
375
|
|
|
276
376
|
/**
|
|
@@ -404,6 +504,13 @@ interface RecordInboundArgs {
|
|
|
404
504
|
*/
|
|
405
505
|
export function recordInbound(args: RecordInboundArgs): void {
|
|
406
506
|
if (args.message_id == null) return
|
|
507
|
+
if (!isValidMessageId(args.message_id)) {
|
|
508
|
+
warnHistory(
|
|
509
|
+
`recordInbound: dropping row with invalid message_id=${String(args.message_id)} ` +
|
|
510
|
+
`(chat=${args.chat_id}) — a delivered inbound will be absent from history`,
|
|
511
|
+
)
|
|
512
|
+
return
|
|
513
|
+
}
|
|
407
514
|
const stmt = requireDb().prepare(`
|
|
408
515
|
INSERT OR REPLACE INTO messages
|
|
409
516
|
(chat_id, thread_id, message_id, role, user, user_id, ts, text, attachment_kind, group_id, reply_to_message_id, reply_to_text, forwarded_from, forwarded_from_type, forwarded_from_id, forwarded_date, forwarded_message_id)
|
|
@@ -453,7 +560,35 @@ interface RecordOutboundArgs {
|
|
|
453
560
|
export function recordOutbound(args: RecordOutboundArgs): void {
|
|
454
561
|
if (args.message_ids.length === 0) return
|
|
455
562
|
const ts = args.ts ?? Math.floor(Date.now() / 1000)
|
|
456
|
-
|
|
563
|
+
// Filter out invalid ids (null/undefined/NaN/non-positive) BEFORE the insert.
|
|
564
|
+
// A malformed send result (an API wrapper that resolved without a real message
|
|
565
|
+
// object) would otherwise inject a NULL/NaN message_id: the NOT NULL PRIMARY
|
|
566
|
+
// KEY throws, and the caller's `catch {}` swallows it — the delivered reply is
|
|
567
|
+
// then absent from history AND the failure is invisible. This was the shape of
|
|
568
|
+
// the 2026-07-16 turn-flush loss (18944/18958 delivered, never recorded). We
|
|
569
|
+
// log loudly and record only the valid rows instead of losing them silently.
|
|
570
|
+
const validRows: Array<{ id: number; text: string; attachKind: string | null }> = []
|
|
571
|
+
for (let i = 0; i < args.message_ids.length; i++) {
|
|
572
|
+
const id = args.message_ids[i]
|
|
573
|
+
if (!isValidMessageId(id)) {
|
|
574
|
+
warnHistory(
|
|
575
|
+
`recordOutbound: dropping chunk ${i} with invalid message_id=${String(id)} ` +
|
|
576
|
+
`(chat=${args.chat_id}) — a delivered outbound will be absent from history, ` +
|
|
577
|
+
`blinding the reply-backstop already-replied suppression`,
|
|
578
|
+
)
|
|
579
|
+
continue
|
|
580
|
+
}
|
|
581
|
+
validRows.push({
|
|
582
|
+
id,
|
|
583
|
+
// Outbound redaction: the agent→user direction has no other secret scrub,
|
|
584
|
+
// so this is the chokepoint that keeps an agent-echoed secret out of the
|
|
585
|
+
// message store. Masks the secret bytes in place; surrounding text kept.
|
|
586
|
+
text: redact(args.texts[i] ?? ''),
|
|
587
|
+
attachKind: args.attachment_kinds?.[i] ?? null,
|
|
588
|
+
})
|
|
589
|
+
}
|
|
590
|
+
if (validRows.length === 0) return
|
|
591
|
+
const groupId = validRows[0]!.id
|
|
457
592
|
const stmt = requireDb().prepare(`
|
|
458
593
|
INSERT OR REPLACE INTO messages
|
|
459
594
|
(chat_id, thread_id, message_id, role, user, user_id, ts, text, attachment_kind, group_id)
|
|
@@ -463,30 +598,25 @@ export function recordOutbound(args: RecordOutboundArgs): void {
|
|
|
463
598
|
// writes if the process dies mid-loop. The transaction signature is
|
|
464
599
|
// typed as variadic-unknown for genericity; cast the typed callback
|
|
465
600
|
// through the wider shape.
|
|
466
|
-
const tx = requireDb().transaction(((rows: Array<
|
|
467
|
-
for (const
|
|
468
|
-
stmt.run(
|
|
469
|
-
args.chat_id,
|
|
470
|
-
args.thread_id ?? null,
|
|
471
|
-
msgId,
|
|
472
|
-
ts,
|
|
473
|
-
text,
|
|
474
|
-
attachKind,
|
|
475
|
-
groupId,
|
|
476
|
-
)
|
|
601
|
+
const tx = requireDb().transaction(((rows: Array<{ id: number; text: string; attachKind: string | null }>) => {
|
|
602
|
+
for (const r of rows) {
|
|
603
|
+
stmt.run(args.chat_id, args.thread_id ?? null, r.id, ts, r.text, r.attachKind, groupId)
|
|
477
604
|
}
|
|
478
605
|
}) as (...args: unknown[]) => unknown)
|
|
479
|
-
//
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
606
|
+
// Surface a write failure LOUDLY before rethrowing. Callers wrap this in a
|
|
607
|
+
// `catch {}` / caller-shaped catch; without this log an insert failure (disk
|
|
608
|
+
// full, read-only mount, lock exhaustion) would be completely invisible —
|
|
609
|
+
// exactly the diagnostic gap the 2026-07-16 incident exposed. Rethrow so the
|
|
610
|
+
// caller's existing control flow is unchanged.
|
|
611
|
+
try {
|
|
612
|
+
tx(validRows)
|
|
613
|
+
} catch (err) {
|
|
614
|
+
warnHistory(
|
|
615
|
+
`recordOutbound: INSERT failed (chat=${args.chat_id} ids=[${validRows.map((r) => r.id).join(',')}]): ` +
|
|
616
|
+
`${err instanceof Error ? err.message : String(err)} — this outbound will be absent from history`,
|
|
617
|
+
)
|
|
618
|
+
throw err
|
|
619
|
+
}
|
|
490
620
|
}
|
|
491
621
|
|
|
492
622
|
interface RecordEditArgs {
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
import {
|
|
31
31
|
detectModelUnavailable,
|
|
32
32
|
isLitellmProxyLocal429,
|
|
33
|
+
isLitellmProxyAuthMisconfig,
|
|
33
34
|
parseResetTime,
|
|
34
35
|
} from './model-unavailable.js'
|
|
35
36
|
import { classify429Detail } from './throttle-tier.js'
|
|
@@ -46,6 +47,7 @@ export type LlmErrorKind =
|
|
|
46
47
|
| 'overload_529'
|
|
47
48
|
| 'quota_wall'
|
|
48
49
|
| 'auth'
|
|
50
|
+
| 'infra_misconfig'
|
|
49
51
|
| 'transient'
|
|
50
52
|
| 'unknown'
|
|
51
53
|
|
|
@@ -150,8 +152,22 @@ export function parseLlmError(
|
|
|
150
152
|
function classifyKindAndSource(text: string): { kind: LlmErrorKind; source: LlmErrorSource } {
|
|
151
153
|
const lower = text.toLowerCase()
|
|
152
154
|
|
|
155
|
+
// 0. LiteLLM-proxy AUTH misconfig — checked BEFORE the auth branch. The
|
|
156
|
+
// proxy's internal fallback re-dispatched WITHOUT the OAuth header onto a
|
|
157
|
+
// keyless deployment, so Anthropic 401'd it ("x-api-key header is
|
|
158
|
+
// required"). This is an OPERATOR-only infra fault, NOT an end-user login
|
|
159
|
+
// wall — never the user-facing 'auth' kind (which renders a re-auth card a
|
|
160
|
+
// non-operator user cannot act on). Source is the local proxy, not
|
|
161
|
+
// Anthropic. See isLitellmProxyAuthMisconfig for provenance.
|
|
162
|
+
if (isLitellmProxyAuthMisconfig(text)) {
|
|
163
|
+
return { kind: 'infra_misconfig', source: 'litellm-local' }
|
|
164
|
+
}
|
|
165
|
+
|
|
153
166
|
// 1. Auth — always terminal, always actionable.
|
|
154
167
|
const claudeKind = classifyClaudeError({ message: text, type: text })
|
|
168
|
+
if (claudeKind === 'proxy-misconfig') {
|
|
169
|
+
return { kind: 'infra_misconfig', source: 'litellm-local' }
|
|
170
|
+
}
|
|
155
171
|
if (claudeKind === 'credentials-expired' || claudeKind === 'credentials-invalid') {
|
|
156
172
|
return { kind: 'auth', source: 'anthropic' }
|
|
157
173
|
}
|
|
@@ -212,6 +228,8 @@ function buildCoreText(kind: LlmErrorKind, source: LlmErrorSource): string {
|
|
|
212
228
|
return 'Usage limit reached on this Claude subscription.'
|
|
213
229
|
case 'auth':
|
|
214
230
|
return 'Claude login needs re-authentication.'
|
|
231
|
+
case 'infra_misconfig':
|
|
232
|
+
return 'Local model-gateway auth misconfig (proxy fallback dropped the OAuth header).'
|
|
215
233
|
case 'transient':
|
|
216
234
|
return source === 'network'
|
|
217
235
|
? "Couldn't reach Anthropic (network) — retrying automatically."
|
|
@@ -280,6 +298,10 @@ function buildRecommendation(parsed: ParsedLlmError, tz: string): string | undef
|
|
|
280
298
|
switch (parsed.kind) {
|
|
281
299
|
case 'auth':
|
|
282
300
|
return '→ Re-authenticate this account to continue.'
|
|
301
|
+
case 'infra_misconfig':
|
|
302
|
+
// Operator-facing — NO re-auth wording (the login is fine). Points at the
|
|
303
|
+
// real remedy: the local LiteLLM proxy fallback config.
|
|
304
|
+
return '→ Fix the LiteLLM proxy fallback config (deployment missing OAuth passthrough).'
|
|
283
305
|
case 'quota_wall': {
|
|
284
306
|
const reset = formatResetClock(parsed.resetAt, tz)
|
|
285
307
|
return reset
|
|
@@ -353,6 +375,8 @@ function kindEmoji(kind: LlmErrorKind): string {
|
|
|
353
375
|
return '⚠️'
|
|
354
376
|
case 'auth':
|
|
355
377
|
return '🔑'
|
|
378
|
+
case 'infra_misconfig':
|
|
379
|
+
return '🛠️'
|
|
356
380
|
case 'transient':
|
|
357
381
|
return '🌐'
|
|
358
382
|
case 'unknown':
|
|
@@ -195,6 +195,61 @@ export function isLitellmProxyLocal429(text: string): boolean {
|
|
|
195
195
|
return litellmV3LimiterSignalPair.every(s => lower.includes(s))
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
/**
|
|
199
|
+
* True when `text` is a LiteLLM-proxy-LOCAL AUTH misconfiguration — the
|
|
200
|
+
* proxy-fallback keyless-401 class, NOT a genuine end-user credential wall.
|
|
201
|
+
*
|
|
202
|
+
* PROVENANCE (incident, 2026-07-16). LiteLLM's internal model-fallback chain
|
|
203
|
+
* (`gpt-oss-20b -> [gpt-oss-20b-openrouter, claude-sonnet-5]`) re-dispatches a
|
|
204
|
+
* failed primary WITHOUT forwarding the client's OAuth `Authorization` header.
|
|
205
|
+
* The `claude-sonnet-5` deployment is deliberately keyless (passthrough auth —
|
|
206
|
+
* it expects the forwarded OAuth), so Anthropic rejects the header-less
|
|
207
|
+
* fallback with a 401 `authentication_error` whose message is
|
|
208
|
+
* "x-api-key header is required". Switchroom agents authenticate the unmodified
|
|
209
|
+
* `claude` CLI with an OAuth Bearer token and NEVER an `x-api-key`, so that
|
|
210
|
+
* demand can only originate from the proxy dropping the header on a keyless
|
|
211
|
+
* fallback deployment — a host-side infra misconfig for the OPERATOR to fix,
|
|
212
|
+
* never a login problem an end user (or even the operator's OAuth) can act on.
|
|
213
|
+
*
|
|
214
|
+
* Misclassifying it (as `classifyClaudeError` did → `credentials-invalid` →
|
|
215
|
+
* a "🔑 re-authenticate" card) is doubly wrong: wrong AUDIENCE (a non-operator
|
|
216
|
+
* user like Lisa cannot re-auth anything) and wrong DIAGNOSIS (the login is
|
|
217
|
+
* fine; the proxy fallback config is not). Detecting it here lets both
|
|
218
|
+
* classifiers route it to the operator-only infra surface instead.
|
|
219
|
+
*
|
|
220
|
+
* Detection (deterministic wording, mirrors `isLitellmProxyLocal429`):
|
|
221
|
+
* - the definitive "x-api-key header is required" marker (impossible for our
|
|
222
|
+
* OAuth flow — only the keyless-proxy path emits it), OR
|
|
223
|
+
* - an `authentication_error` CO-OCCURRING with the proxy-FALLBACK-specific
|
|
224
|
+
* structural pair: "fallback" re-dispatch provenance AND an explicit
|
|
225
|
+
* "x-api-key" mention.
|
|
226
|
+
*
|
|
227
|
+
* PRECISION over recall (#3293 review finding 2): an earlier draft matched any
|
|
228
|
+
* `authentication_error` + (litellm|proxy) + (fallback|x-api-key|api_key) —
|
|
229
|
+
* broad enough that a GENUINE OAuth expiry wrapped in a proxy envelope that
|
|
230
|
+
* merely mentions "api_key" would misdiagnose as proxy-misconfig (still
|
|
231
|
+
* operator-visible, but the recommendation would point at the proxy config
|
|
232
|
+
* instead of a re-auth). Both classes route operator-only now, so an ambiguous
|
|
233
|
+
* envelope deliberately KEEPS the credentials-invalid/-expired diagnosis;
|
|
234
|
+
* only the unambiguous keyless-fallback signature classifies as misconfig.
|
|
235
|
+
* Never throws on weird input.
|
|
236
|
+
*/
|
|
237
|
+
export function isLitellmProxyAuthMisconfig(text: string): boolean {
|
|
238
|
+
if (typeof text !== 'string' || text.length === 0) return false
|
|
239
|
+
const sample = text.length > 16_384 ? text.slice(0, 16_384) : text
|
|
240
|
+
const lower = sample.toLowerCase()
|
|
241
|
+
// Definitive marker — see provenance above.
|
|
242
|
+
if (lower.includes('x-api-key header is required')) return true
|
|
243
|
+
// Structural signature: an authentication_error carrying BOTH the fallback
|
|
244
|
+
// re-dispatch provenance AND an explicit x-api-key mention. A proxy envelope
|
|
245
|
+
// that merely mentions litellm/proxy/api_key stays on the credentials
|
|
246
|
+
// diagnosis (ambiguous → not misconfig).
|
|
247
|
+
const isAuthErr =
|
|
248
|
+
lower.includes('authentication_error') || lower.includes('authenticationerror')
|
|
249
|
+
if (!isAuthErr) return false
|
|
250
|
+
return lower.includes('fallback') && lower.includes('x-api-key')
|
|
251
|
+
}
|
|
252
|
+
|
|
198
253
|
/**
|
|
199
254
|
* Best-effort extraction of the limit detail LiteLLM embeds in its
|
|
200
255
|
* proxy-local 429 bodies, for instrumentation (the `rate_limit_429_classified`
|