switchroom 0.19.39 → 0.19.41
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/agent-scheduler/index.js +10 -1
- package/dist/auth-broker/index.js +12 -3
- package/dist/cli/notion-write-pretool.mjs +10 -1
- package/dist/cli/switchroom.js +562 -196
- package/dist/host-control/main.js +287 -20
- package/dist/vault/approvals/kernel-server.js +12 -3
- package/dist/vault/broker/server.js +12 -3
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +10 -0
- package/telegram-plugin/bridge/bridge.ts +2 -2
- package/telegram-plugin/dist/bridge/bridge.js +10 -2
- package/telegram-plugin/dist/gateway/gateway.js +549 -232
- package/telegram-plugin/dist/server.js +10 -2
- package/telegram-plugin/gateway/backstop-delivery.ts +48 -0
- package/telegram-plugin/gateway/checklist-fallback.ts +370 -0
- package/telegram-plugin/gateway/compaction-marker.ts +84 -0
- package/telegram-plugin/gateway/gateway.ts +72 -72
- package/telegram-plugin/gateway/liveness-wiring.ts +15 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +20 -0
- package/telegram-plugin/gateway/outbox-sweep.ts +116 -18
- package/telegram-plugin/gateway/silence-poke-session-event.ts +13 -0
- package/telegram-plugin/gateway/stream-render.ts +39 -1
- package/telegram-plugin/gateway/turn-record-status.ts +80 -0
- package/telegram-plugin/hooks/compaction-marker-precompact.mjs +70 -0
- package/telegram-plugin/hooks/hooks.json +11 -0
- package/telegram-plugin/session-tail.ts +20 -0
- package/telegram-plugin/silence-poke.ts +28 -0
- package/telegram-plugin/tests/checklist-fallback.test.ts +317 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +10 -6
- package/telegram-plugin/tests/outbox-delivery.test.ts +38 -1
- package/telegram-plugin/tests/outbox-flush-ack-claim-race.test.ts +213 -0
- package/telegram-plugin/tests/outbox-reply-then-recap-e2e.test.ts +1 -1
- package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +4 -4
- package/telegram-plugin/tests/outbox-sweep-listen-button.test.ts +71 -8
- package/telegram-plugin/tests/send-reply-golden.test.ts +47 -0
- package/telegram-plugin/tests/silence-poke-compaction.test.ts +222 -0
- package/telegram-plugin/tests/turn-record-status.test.ts +62 -0
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
type AskUserOutcome,
|
|
36
36
|
} from '../ask-user.js'
|
|
37
37
|
import { redactAskUserFields, redactChecklistFields } from '../outbound-field-redact.js'
|
|
38
|
+
import { createChecklistStore, checklistStoreKey, performSendChecklist, performUpdateChecklist, sendChecklistToolText, updateChecklistToolText } from './checklist-fallback.js'
|
|
38
39
|
import { parseInterruptMarker } from '../interrupt-marker.js'
|
|
39
40
|
import {
|
|
40
41
|
ToolFlightTracker,
|
|
@@ -1383,65 +1384,29 @@ let _rawEditMessageChecklist: unknown
|
|
|
1383
1384
|
/** True when the connected Telegram Bot API supports native checklists. Set in initGatewayBot() (#2996 P0b). */
|
|
1384
1385
|
let CHECKLIST_API_AVAILABLE = false
|
|
1385
1386
|
|
|
1386
|
-
/**
|
|
1387
|
-
*
|
|
1388
|
-
*
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
chat_id: string
|
|
1393
|
-
title: string
|
|
1394
|
-
tasks: Array<{ text: string; done?: boolean }>
|
|
1395
|
-
message_thread_id?: number
|
|
1396
|
-
reply_to_message_id?: number
|
|
1397
|
-
protect_content?: boolean
|
|
1398
|
-
}): Promise<{ message_id: number }> {
|
|
1399
|
-
if (!CHECKLIST_API_AVAILABLE) {
|
|
1400
|
-
throw new Error('sendChecklist is not available in this grammY/Telegram Bot API version')
|
|
1401
|
-
}
|
|
1402
|
-
const MAX_TASKS = 30
|
|
1403
|
-
if (args.tasks.length > MAX_TASKS) {
|
|
1404
|
-
throw new Error(`checklist exceeds ${MAX_TASKS}-task limit (got ${args.tasks.length})`)
|
|
1405
|
-
}
|
|
1406
|
-
const result = await (_rawSendChecklist as (p: Record<string, unknown>) => Promise<{ message_id: number }>)({
|
|
1407
|
-
chat_id: Number(args.chat_id),
|
|
1408
|
-
title: args.title,
|
|
1409
|
-
tasks: args.tasks.map(t => ({ text: t.text, ...(t.done != null ? { is_completed: t.done } : {}) })),
|
|
1410
|
-
...(args.message_thread_id != null ? { message_thread_id: args.message_thread_id } : {}),
|
|
1411
|
-
...(args.reply_to_message_id != null ? { reply_to_message_id: args.reply_to_message_id } : {}),
|
|
1412
|
-
...(args.protect_content === true ? { protect_content: true } : {}),
|
|
1413
|
-
})
|
|
1387
|
+
/** bot.api.raw.sendChecklist with a payload pre-built by checklist-fallback.ts
|
|
1388
|
+
* (nested `checklist` object, per-task integer ids, business_connection_id —
|
|
1389
|
+
* the old flat shape got `400: parameter "checklist" is required`). */
|
|
1390
|
+
async function rawSendChecklist(payload: Record<string, unknown>): Promise<{ message_id: number }> {
|
|
1391
|
+
if (!CHECKLIST_API_AVAILABLE) throw new Error('sendChecklist is not available in this grammY/Telegram Bot API version')
|
|
1392
|
+
const result = await (_rawSendChecklist as (p: Record<string, unknown>) => Promise<{ message_id: number }>)(payload)
|
|
1414
1393
|
return { message_id: result.message_id }
|
|
1415
1394
|
}
|
|
1416
1395
|
|
|
1417
|
-
/**
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
await (_rawEditMessageChecklist as (p: Record<string, unknown>) => Promise<unknown>)({
|
|
1432
|
-
chat_id: Number(args.chat_id),
|
|
1433
|
-
message_id: Number(args.message_id),
|
|
1434
|
-
...(args.title != null ? { title: args.title } : {}),
|
|
1435
|
-
...(args.tasks != null
|
|
1436
|
-
? {
|
|
1437
|
-
tasks: args.tasks.map(t => ({
|
|
1438
|
-
...(t.id != null ? { id: Number(t.id) } : {}),
|
|
1439
|
-
...(t.text != null ? { text: t.text } : {}),
|
|
1440
|
-
...(t.done != null ? { is_completed: t.done } : {}),
|
|
1441
|
-
})),
|
|
1442
|
-
}
|
|
1443
|
-
: {}),
|
|
1444
|
-
})
|
|
1396
|
+
/** bot.api.raw.editMessageChecklist with a pre-built payload (the native edit REPLACES the whole checklist, so checklist-fallback.ts sends full state). */
|
|
1397
|
+
async function rawEditMessageChecklist(payload: Record<string, unknown>): Promise<void> {
|
|
1398
|
+
if (!CHECKLIST_API_AVAILABLE) throw new Error('editMessageChecklist is not available in this grammY/Telegram Bot API version')
|
|
1399
|
+
await (_rawEditMessageChecklist as (p: Record<string, unknown>) => Promise<unknown>)(payload)
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
/** Per-message checklist state — update_checklist re-renders from it (process-local; post-restart updates degrade gracefully). */
|
|
1403
|
+
const checklistStore = createChecklistStore()
|
|
1404
|
+
|
|
1405
|
+
/** Native checklists are Telegram-Business-only (sendChecklist REQUIRES a business_connection_id).
|
|
1406
|
+
* No fleet config plumbing exists yet; this env var is the opt-in — unset (the normal case) means text fallback. */
|
|
1407
|
+
function resolveBusinessConnectionId(): string | undefined {
|
|
1408
|
+
const v = process.env.SWITCHROOM_TELEGRAM_BUSINESS_CONNECTION_ID?.trim()
|
|
1409
|
+
return v ? v : undefined
|
|
1445
1410
|
}
|
|
1446
1411
|
|
|
1447
1412
|
const chatLock = createChatLock()
|
|
@@ -2463,7 +2428,7 @@ async function deliverAnswer(args: {
|
|
|
2463
2428
|
replyToMessageId: number | null
|
|
2464
2429
|
/** #3282 captured-answer RESUME (see captured-answer-resume.ts): re-deliver the SAME byte-identical chunks + pre-hydrate the ledger (unsent tail only; a landed chunk is re-probed, never re-sent). */
|
|
2465
2430
|
resume?: { snapshot: CapturedDeliverySnapshot; hydrate: (ledger: BackstopDeliveryLedger, turnId: string) => void }
|
|
2466
|
-
}): Promise<{ sentIds: number[]; chunkCount: number; delivered: boolean; exhausted: boolean; landedUnconfirmed: number }> {
|
|
2431
|
+
/** Duplicate-message race fix — forwarded to `runBackstopDelivery` as `onAckClaim` (claims the nonce at send-ack, before the read-back probe; see backstop-delivery.ts). */ onAckClaim?: (sentIds: number[]) => void }): Promise<{ sentIds: number[]; chunkCount: number; delivered: boolean; exhausted: boolean; landedUnconfirmed: number }> {
|
|
2467
2432
|
const { chatId, turnId } = args
|
|
2468
2433
|
// Spacers into `\n\n` gaps then split (as executeReply); a resume re-delivers the EXACT captured chunks (byte-stable, no re-split).
|
|
2469
2434
|
const chunks = args.resume
|
|
@@ -2574,7 +2539,7 @@ async function deliverAnswer(args: {
|
|
|
2574
2539
|
args.resume ? null : args.cardMessageId,
|
|
2575
2540
|
{
|
|
2576
2541
|
sendChunk,
|
|
2577
|
-
readBack,
|
|
2542
|
+
readBack, onAckClaim: args.onAckClaim,
|
|
2578
2543
|
recordOutbound: HISTORY_ENABLED
|
|
2579
2544
|
? (messageIds, texts) => {
|
|
2580
2545
|
try {
|
|
@@ -5038,7 +5003,7 @@ function emitTurnRecord(turn: CurrentTurn, endedAt: number): void {
|
|
|
5038
5003
|
startedAt: turn.startedAt,
|
|
5039
5004
|
toolCallCount: turn.toolCallCount ?? 0,
|
|
5040
5005
|
turnId: turn.turnId,
|
|
5041
|
-
finalAnswerDelivered: turn.finalAnswerDelivered,
|
|
5006
|
+
finalAnswerDelivered: turn.finalAnswerDelivered, replyCalled: turn.replyCalled, // replyCalled: honest delivery-route signal (turn-record-status.ts computeTurnRoute)
|
|
5042
5007
|
deliveryOutcome: turn.deliveryOutcome, landedUnconfirmed: turn.landedUnconfirmed,
|
|
5043
5008
|
},
|
|
5044
5009
|
endedAt,
|
|
@@ -9838,7 +9803,7 @@ function runDeliveryConfirmSweep(): void {
|
|
|
9838
9803
|
const _deliveryConfirmSweep = isGatewayMain ? setInterval(runDeliveryConfirmSweep, DELIVERY_CONFIRM_SWEEP_MS) : undefined
|
|
9839
9804
|
_deliveryConfirmSweep?.unref?.()
|
|
9840
9805
|
|
|
9841
|
-
startOutboxSweep({ isGatewayMain, stateDir: STATE_DIR, getBot: () => bot, getTurnsDb: () => turnsDb, dedupCheck: (c, t, x) => outboundDedup.check(c, t, x, Date.now()) != null, resolveReplyMarkup: makeOutboxListenMarkupResolver({ resolveVoiceOutPlan: (t) => resolveVoiceOutPlan(loadAccess().voice_out, t), cachePut: (token, payload) => voiceOnDemandCache.put(token, payload), eagerVoiceEnabled, enqueuePreSynth: (j) => voicePreSynthQueue.enqueue(j) }), log: (l) => process.stderr.write(l) }) // outbox: single deliverer for Stop-hook prose; resolveReplyMarkup keeps the #3502 Listen button on net-delivered answers (../outbox.ts)
|
|
9806
|
+
startOutboxSweep({ isGatewayMain, stateDir: STATE_DIR, getBot: () => bot, getTurnsDb: () => turnsDb, dedupCheck: (c, t, x) => outboundDedup.check(c, t, x, Date.now()) != null, recordOutbound: HISTORY_ENABLED ? (chatId, threadId, message_ids, texts) => { try { recordOutbound({ chat_id: chatId, thread_id: threadId, message_ids, texts }) } catch { /* best-effort */ } } : undefined, resolveReplyMarkup: makeOutboxListenMarkupResolver({ resolveVoiceOutPlan: (t) => resolveVoiceOutPlan(loadAccess().voice_out, t), cachePut: (token, payload) => voiceOnDemandCache.put(token, payload), eagerVoiceEnabled, enqueuePreSynth: (j) => voicePreSynthQueue.enqueue(j) }), log: (l) => process.stderr.write(l) }) // outbox: single deliverer for Stop-hook prose; resolveReplyMarkup keeps the #3502 Listen button on net-delivered answers; recordOutbound persists the net-delivered answer to history.db (../outbox.ts)
|
|
9842
9807
|
|
|
9843
9808
|
// #1445 cross-turn pending-async ambient. When a turn ends after the
|
|
9844
9809
|
// model dispatched background async work (Agent / Task / Bash run-in-
|
|
@@ -12083,17 +12048,35 @@ async function executeSendChecklist(args: Record<string, unknown>): Promise<{ co
|
|
|
12083
12048
|
(t) => redactOutboundText(t, 'send_checklist'),
|
|
12084
12049
|
)
|
|
12085
12050
|
|
|
12086
|
-
|
|
12087
|
-
|
|
12088
|
-
|
|
12089
|
-
|
|
12051
|
+
// Graceful degradation: native sendChecklist is Business-account-only, so
|
|
12052
|
+
// ordinary chats (the fleet norm) get a formatted text render instead of a
|
|
12053
|
+
// raw Telegram 400 (rationale + orchestration in checklist-fallback.ts).
|
|
12054
|
+
const literal = (loadAccess().parseMode ?? 'html') === 'text'
|
|
12055
|
+
const sendOpts = {
|
|
12090
12056
|
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
12091
|
-
...(replyTo != null ? {
|
|
12057
|
+
...(replyTo != null ? { reply_parameters: { message_id: replyTo } } : {}),
|
|
12092
12058
|
...(protectContent ? { protect_content: true } : {}),
|
|
12093
|
-
}
|
|
12059
|
+
}
|
|
12060
|
+
const result = await performSendChecklist({
|
|
12061
|
+
businessConnectionId: resolveBusinessConnectionId(),
|
|
12062
|
+
nativeAvailable: CHECKLIST_API_AVAILABLE,
|
|
12063
|
+
sendNative: rawSendChecklist,
|
|
12064
|
+
sendText: (text) => robustApiCall(
|
|
12065
|
+
(): Promise<{ message_id: number }> =>
|
|
12066
|
+
literal
|
|
12067
|
+
// allow-raw-bot-api: literal checklist text-fallback send routed through robustApiCall
|
|
12068
|
+
? lockedBot.api.sendMessage(chat_id, text, sendOpts as never)
|
|
12069
|
+
// allow-raw-bot-api: checklist text-fallback send routed through robustApiCall
|
|
12070
|
+
: lockedBot.api.sendRichMessage(chat_id, richMessage(text), sendOpts as never),
|
|
12071
|
+
{ verb: 'sendMessage', chat_id, threadId },
|
|
12072
|
+
),
|
|
12073
|
+
literalText: literal,
|
|
12074
|
+
log: (l) => process.stderr.write(`telegram gateway: ${l}`),
|
|
12075
|
+
}, { title: redactedTitle!, tasks: redactedTasks!, chatId: Number(chat_id), replyToMessageId: replyTo, protectContent })
|
|
12094
12076
|
|
|
12095
|
-
|
|
12096
|
-
|
|
12077
|
+
checklistStore.set(checklistStoreKey(chat_id, result.message_id), result.state)
|
|
12078
|
+
process.stderr.write(`telegram gateway: send_checklist: sent chatId=${chat_id} messageId=${result.message_id} tasks=${tasks.length} mode=${result.mode}\n`)
|
|
12079
|
+
return { content: [{ type: 'text', text: sendChecklistToolText(result) }] }
|
|
12097
12080
|
}
|
|
12098
12081
|
|
|
12099
12082
|
/**
|
|
@@ -12180,10 +12163,27 @@ async function executeUpdateChecklist(args: Record<string, unknown>): Promise<{
|
|
|
12180
12163
|
(t) => redactOutboundText(t, 'update_checklist'),
|
|
12181
12164
|
)
|
|
12182
12165
|
|
|
12183
|
-
|
|
12184
|
-
|
|
12185
|
-
|
|
12186
|
-
|
|
12166
|
+
// Graceful degradation (checklist-fallback.ts): the patch is applied to the
|
|
12167
|
+
// stored per-message state and the message re-rendered in the mode it was
|
|
12168
|
+
// sent in. Failures come back structured, never as a raw Telegram 400.
|
|
12169
|
+
const literal = (loadAccess().parseMode ?? 'html') === 'text'
|
|
12170
|
+
const key = checklistStoreKey(chat_id, message_id)
|
|
12171
|
+
const result = await performUpdateChecklist({
|
|
12172
|
+
state: checklistStore.get(key),
|
|
12173
|
+
businessConnectionId: resolveBusinessConnectionId(),
|
|
12174
|
+
nativeAvailable: CHECKLIST_API_AVAILABLE,
|
|
12175
|
+
editNative: rawEditMessageChecklist,
|
|
12176
|
+
// allow-raw-bot-api: checklist text-fallback edit routed through robustApiCall
|
|
12177
|
+
editText: async (text) => { await robustApiCall(() => lockedBot.api.editMessageText(chat_id, Number(message_id), literal ? text : richMessage(text))) },
|
|
12178
|
+
literalText: literal,
|
|
12179
|
+
log: (l) => process.stderr.write(`telegram gateway: ${l}`),
|
|
12180
|
+
chatId: Number(chat_id),
|
|
12181
|
+
messageId: Number(message_id),
|
|
12182
|
+
}, { title: redactedTitle, tasks: redactedTasks })
|
|
12183
|
+
|
|
12184
|
+
if (result.ok) checklistStore.set(key, result.state)
|
|
12185
|
+
process.stderr.write(`telegram gateway: update_checklist: ${result.ok ? `updated mode=${result.mode}` : `failed reason=${result.reason}`} chatId=${chat_id} messageId=${message_id}\n`)
|
|
12186
|
+
return { content: [{ type: 'text', text: updateChecklistToolText(result, Number(message_id)) }] }
|
|
12187
12187
|
}
|
|
12188
12188
|
|
|
12189
12189
|
/**
|
|
@@ -25,6 +25,7 @@ import { logStreamingEvent } from '../streaming-metrics.js'
|
|
|
25
25
|
import { clearSilentEndState } from '../silent-end.js'
|
|
26
26
|
import { purgeStaleTurnsForChat } from './turn-state-purge.js'
|
|
27
27
|
import { removeTurnActiveMarker, readTurnActiveMarkerAgeMs } from './turn-active-marker.js'
|
|
28
|
+
import { readCompactionMarkerAgeMs } from './compaction-marker.js'
|
|
28
29
|
import { decideHangRestart } from './hang-restart-decision.js'
|
|
29
30
|
import { recordTurnEnd } from '../registry/turns-schema.js'
|
|
30
31
|
import { hostdGetStatusOnce } from './hostd-dispatch.js'
|
|
@@ -70,6 +71,20 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
|
|
|
70
71
|
thresholdsMs: { fallback: SILENCE_FALLBACK_MS, fallbackHardCeiling: SILENCE_FALLBACK_HARD_MS, floor: SILENCE_FLOOR_MS },
|
|
71
72
|
deferFallbackWhileToolInFlight: SILENCE_DEFER_INFLIGHT_TOOLS,
|
|
72
73
|
isLegitimatelyWorking: (key) => isLegitimatelyWorking(key),
|
|
74
|
+
// #4058 — mid-turn auto-compaction defer. The PreCompact hook writes the
|
|
75
|
+
// compaction marker at compaction START (the transcript only records the
|
|
76
|
+
// END, via `compact_boundary`); while the marker is present AND younger
|
|
77
|
+
// than the fallback hard ceiling, silence-poke defers the 300s fallback the
|
|
78
|
+
// same way it does for an in-flight tool. The age bound makes a marker
|
|
79
|
+
// leaked by a crash (boundary never observed) self-heal: it can never hold
|
|
80
|
+
// a future genuine wedge past one ceiling window. The marker is process-
|
|
81
|
+
// wide, not keyed — the gateway runs ONE Claude session, so a compaction
|
|
82
|
+
// belongs to whichever turn is live (same accepted trade-off as
|
|
83
|
+
// `toolFlightTracker` in gateway.ts `isLegitimatelyWorking`).
|
|
84
|
+
isCompactionInFlight: () => {
|
|
85
|
+
const ageMs = readCompactionMarkerAgeMs(STATE_DIR)
|
|
86
|
+
return ageMs != null && ageMs >= 0 && ageMs < SILENCE_FALLBACK_HARD_MS
|
|
87
|
+
},
|
|
73
88
|
// #3552 — orphan-state reaper predicate. Deliberately the SAME condition the
|
|
74
89
|
// `onFrameworkFallback` late-fire guard below uses: no `activeTurnStartedAt`
|
|
75
90
|
// entry AND no current turn ⇒ the turn this state belongs to is over. Note
|
|
@@ -2663,6 +2663,8 @@ export async function deliverCapturedProse(
|
|
|
2663
2663
|
const plainChunks = splitMarkdownChunks(plain, RICH_MESSAGE_MAX_CHARS)
|
|
2664
2664
|
try {
|
|
2665
2665
|
let liveThreadId: number | undefined = threadId
|
|
2666
|
+
const plainSentIds: number[] = []
|
|
2667
|
+
const plainSentTexts: string[] = []
|
|
2666
2668
|
for (const c of plainChunks) {
|
|
2667
2669
|
// Plain sendMessage — NO parse_mode / rich rendering — so a markdown
|
|
2668
2670
|
// construct that made sendRichMessage 400 is sent verbatim instead.
|
|
@@ -2676,6 +2678,8 @@ export async function deliverCapturedProse(
|
|
|
2676
2678
|
),
|
|
2677
2679
|
{ threadId: liveThreadId, chat_id: chatId, verb: 'captured-prose-plain-fallback.sendMessage' },
|
|
2678
2680
|
)
|
|
2681
|
+
const sentId = (sent as { message_id?: number }).message_id
|
|
2682
|
+
if (sentId != null) { plainSentIds.push(sentId); plainSentTexts.push(c) }
|
|
2679
2683
|
if (liveThreadId != null && (sent as { message_thread_id?: number }).message_thread_id == null) {
|
|
2680
2684
|
liveThreadId = undefined
|
|
2681
2685
|
}
|
|
@@ -2683,6 +2687,22 @@ export async function deliverCapturedProse(
|
|
|
2683
2687
|
// The real answer reached the user via plain text — record it so a late
|
|
2684
2688
|
// reply-tool retry with the same content is deduped at its send site.
|
|
2685
2689
|
outboundDedup.record(chatId, threadId, text, Date.now(), registryKey)
|
|
2690
|
+
// Persist parity: the recovered answer reached the user, so it must land in
|
|
2691
|
+
// history.db too (mirrors the canonical persist above at the recordOutbound
|
|
2692
|
+
// reply site). Without this the plain-text-recovered answer is silently
|
|
2693
|
+
// absent from get_recent_messages / handoff briefings / the represent
|
|
2694
|
+
// guard's outbound counting. Best-effort — the delivery already succeeded.
|
|
2695
|
+
if (HISTORY_ENABLED && plainSentIds.length > 0) {
|
|
2696
|
+
try {
|
|
2697
|
+
recordOutbound({ chat_id: chatId, thread_id: threadId ?? null, message_ids: plainSentIds, texts: plainSentTexts })
|
|
2698
|
+
} catch (histErr) {
|
|
2699
|
+
process.stderr.write(
|
|
2700
|
+
`telegram gateway: history recordOutbound (captured-prose plain fallback) failed: ${
|
|
2701
|
+
histErr instanceof Error ? histErr.message : String(histErr)
|
|
2702
|
+
}\n`,
|
|
2703
|
+
)
|
|
2704
|
+
}
|
|
2705
|
+
}
|
|
2686
2706
|
process.stderr.write(
|
|
2687
2707
|
`telegram gateway: captured-prose recovered via plain-text fallback ` +
|
|
2688
2708
|
`(chat=${chatId} origin=${originTurnId})\n`,
|
|
@@ -43,6 +43,8 @@ import {
|
|
|
43
43
|
type OutboxRecord,
|
|
44
44
|
} from '../outbox.js'
|
|
45
45
|
import { isShownBlock } from '../shown-ledger.js'
|
|
46
|
+
import { richMessage, isParseEntitiesError } from '../rich-send.js'
|
|
47
|
+
import { splitMarkdownChunks } from '../format.js'
|
|
46
48
|
import { resolveSubagentOriginTurnKey } from '../registry/subagents-schema.js'
|
|
47
49
|
import { createRetryApiCall, retryWithThreadFallback } from '../retry-api-call.js'
|
|
48
50
|
import {
|
|
@@ -51,13 +53,46 @@ import {
|
|
|
51
53
|
makeFloodWaitRecorder,
|
|
52
54
|
} from '../flood-circuit-breaker.js'
|
|
53
55
|
|
|
56
|
+
/**
|
|
57
|
+
* What a sweep `send` reports back. The primary (last) landed message id is the
|
|
58
|
+
* journal's `tgMessageId`; the per-chunk `chunks` are what {@link sweepOutbox}
|
|
59
|
+
* hands to `recordOutbound` so the history row is aligned to the ACTUAL sends
|
|
60
|
+
* (a markdown-aware split can land >1 chunk), exactly like the backstop uses
|
|
61
|
+
* `ledger.entries` (`backstop-delivery.ts`).
|
|
62
|
+
*/
|
|
63
|
+
export interface OutboxSendResult {
|
|
64
|
+
/** Primary (last) landed message id, or `undefined` when nothing was sent. */
|
|
65
|
+
messageId: number | undefined
|
|
66
|
+
/** Every landed chunk in delivery order: its message id + the delivered text. */
|
|
67
|
+
chunks: Array<{ messageId: number; text: string }>
|
|
68
|
+
}
|
|
69
|
+
|
|
54
70
|
export interface OutboxSweepDeps {
|
|
55
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* Deliver `text` to the chat. Resolves to the landed message id(s) + their
|
|
73
|
+
* texts (best-effort) — see {@link OutboxSendResult}. A long body is chunked
|
|
74
|
+
* inside the sender, so more than one message may land.
|
|
75
|
+
*/
|
|
56
76
|
send: (
|
|
57
77
|
chatId: string,
|
|
58
78
|
threadId: number | null,
|
|
59
79
|
text: string,
|
|
60
|
-
) => Promise<
|
|
80
|
+
) => Promise<OutboxSendResult>
|
|
81
|
+
/**
|
|
82
|
+
* Persist the sweep-delivered final answer to history (role='assistant'),
|
|
83
|
+
* the SAME `recordOutbound` the reply path (`outbound-send-path.ts`) and the
|
|
84
|
+
* turn-flush backstop (`backstop-delivery.ts`) call. Without it every
|
|
85
|
+
* net-delivered handback / task-notification answer is silently absent from
|
|
86
|
+
* `history.db`, degrading `get_recent_messages`, the handoff briefing, and the
|
|
87
|
+
* represent-guard's outbound counting. OPTIONAL and called DEFENSIVELY (a
|
|
88
|
+
* missing recorder or a throw never breaks the safety-net delivery).
|
|
89
|
+
*/
|
|
90
|
+
recordOutbound?: (
|
|
91
|
+
chatId: string,
|
|
92
|
+
threadId: number | null,
|
|
93
|
+
messageIds: number[],
|
|
94
|
+
texts: string[],
|
|
95
|
+
) => void
|
|
61
96
|
/** Has this exact text already been delivered to this chat/thread recently? */
|
|
62
97
|
textAlreadyDelivered: (chatId: string, threadId: number | null, text: string) => boolean
|
|
63
98
|
/**
|
|
@@ -241,7 +276,7 @@ export async function sweepOutbox(deps: OutboxSweepDeps): Promise<OutboxSweepSum
|
|
|
241
276
|
const resolvedChat = resolved! // routable implies non-null
|
|
242
277
|
|
|
243
278
|
try {
|
|
244
|
-
const
|
|
279
|
+
const sendResult = await deps.send(
|
|
245
280
|
resolvedChat.chatId,
|
|
246
281
|
resolvedChat.threadId,
|
|
247
282
|
decision.text ?? record.text,
|
|
@@ -250,7 +285,7 @@ export async function sweepOutbox(deps: OutboxSweepDeps): Promise<OutboxSweepSum
|
|
|
250
285
|
{
|
|
251
286
|
turnNonce: record.turnNonce,
|
|
252
287
|
textSha256: record.textSha256,
|
|
253
|
-
tgMessageId: messageId,
|
|
288
|
+
tgMessageId: sendResult.messageId,
|
|
254
289
|
ts: now,
|
|
255
290
|
// #3510 instrumentation: a sweep delivery journals its machine and the
|
|
256
291
|
// record's capture-time reply-already-delivered flag, so a duplicate
|
|
@@ -261,6 +296,22 @@ export async function sweepOutbox(deps: OutboxSweepDeps): Promise<OutboxSweepSum
|
|
|
261
296
|
},
|
|
262
297
|
deps.stateDir,
|
|
263
298
|
)
|
|
299
|
+
// Persist parity (mirrors outbound-send-path.ts + backstop-delivery.ts):
|
|
300
|
+
// record the delivered chunk ids + texts to history so a net-delivered
|
|
301
|
+
// answer is not silently absent from history.db. Best-effort — a missing
|
|
302
|
+
// recorder or a throw must never break this safety-net delivery.
|
|
303
|
+
if (deps.recordOutbound != null && sendResult.chunks.length > 0) {
|
|
304
|
+
try {
|
|
305
|
+
deps.recordOutbound(
|
|
306
|
+
resolvedChat.chatId,
|
|
307
|
+
resolvedChat.threadId,
|
|
308
|
+
sendResult.chunks.map((c) => c.messageId),
|
|
309
|
+
sendResult.chunks.map((c) => c.text),
|
|
310
|
+
)
|
|
311
|
+
} catch {
|
|
312
|
+
/* best-effort — the delivery already landed; never throw here */
|
|
313
|
+
}
|
|
314
|
+
}
|
|
264
315
|
removeClaimed(record.turnNonce, deps.stateDir)
|
|
265
316
|
summary.delivered++
|
|
266
317
|
log(
|
|
@@ -380,7 +431,9 @@ export type OutboxDeliveryMarkup = {
|
|
|
380
431
|
inline_keyboard: Array<Array<{ text: string; callback_data: string }>>
|
|
381
432
|
}
|
|
382
433
|
|
|
383
|
-
/** Minimal bot-api surface the sweep needs to deliver text.
|
|
434
|
+
/** Minimal bot-api surface the sweep needs to deliver text. `sendRichMessage`
|
|
435
|
+
* is the canonical rendered path (raw GFM markdown → entities); `sendMessage`
|
|
436
|
+
* is the plain parse-reject fallback. */
|
|
384
437
|
type OutboxSendBot = {
|
|
385
438
|
api: {
|
|
386
439
|
sendMessage: (
|
|
@@ -388,6 +441,40 @@ type OutboxSendBot = {
|
|
|
388
441
|
text: string,
|
|
389
442
|
opts: object,
|
|
390
443
|
) => Promise<{ message_id?: number }>
|
|
444
|
+
sendRichMessage: (
|
|
445
|
+
chatId: string,
|
|
446
|
+
body: { markdown: string },
|
|
447
|
+
opts: object,
|
|
448
|
+
) => Promise<{ message_id?: number }>
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Send ONE chunk with RENDER PARITY: ship raw GFM markdown through the shared
|
|
454
|
+
* `richMessage` → `sendRichMessage` path — the SAME renderer the reply path
|
|
455
|
+
* uses (`outbound-send-path.ts`) — so a net-delivered answer is formatted
|
|
456
|
+
* identically to a normally-delivered one (no more literal `**bold**`). On a
|
|
457
|
+
* markdown PARSE-REJECT, resend the same chunk as PLAIN text (the raw markdown
|
|
458
|
+
* source is itself readable prose), mirroring the reply path's plaintext
|
|
459
|
+
* fallback (`outbound-send-path.ts`). A length / thread / flood error is NOT
|
|
460
|
+
* caught here — it propagates so `retryWithThreadFallback` and the sweep's
|
|
461
|
+
* claim-release retry handle it, exactly as before.
|
|
462
|
+
*/
|
|
463
|
+
async function sendChunkRich(
|
|
464
|
+
bot: OutboxSendBot,
|
|
465
|
+
chatId: string,
|
|
466
|
+
chunk: string,
|
|
467
|
+
opts: object,
|
|
468
|
+
): Promise<{ message_id?: number }> {
|
|
469
|
+
try {
|
|
470
|
+
// allow-raw-bot-api: caller (createOutboxSend) invokes this inside retryWithThreadFallback + createRetryApiCall
|
|
471
|
+
return await bot.api.sendRichMessage(chatId, richMessage(chunk), opts)
|
|
472
|
+
} catch (err) {
|
|
473
|
+
if (isParseEntitiesError(err)) {
|
|
474
|
+
// allow-raw-bot-api: parse-reject plain fallback, still inside the sweep's retryWithThreadFallback wrapper
|
|
475
|
+
return await bot.api.sendMessage(chatId, chunk, opts)
|
|
476
|
+
}
|
|
477
|
+
throw err
|
|
391
478
|
}
|
|
392
479
|
}
|
|
393
480
|
|
|
@@ -419,19 +506,21 @@ export function createOutboxSend(deps: {
|
|
|
419
506
|
// so sending one chunk of '' would throw every tick and wedge the sweep in
|
|
420
507
|
// a permanent retry (the record never journals → never clears). Return
|
|
421
508
|
// early, matching the pre-refactor loop's zero-chunk behaviour.
|
|
422
|
-
if (text.length === 0) return undefined
|
|
509
|
+
if (text.length === 0) return { messageId: undefined, chunks: [] }
|
|
423
510
|
// Resolve the Listen button / keyboard ONCE from the full answer text; it
|
|
424
511
|
// rides only on the final chunk below.
|
|
425
512
|
const replyMarkup = deps.resolveReplyMarkup?.(chatId, threadId, text)
|
|
426
|
-
// Chunk
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
513
|
+
// Chunk with the markdown-boundary-aware splitter the reply path uses
|
|
514
|
+
// (`splitMarkdownChunks`), NOT a blind fixed-char slice — a raw slice can
|
|
515
|
+
// bisect an inline entity (`**bold**`) and force a parse-reject. Each chunk
|
|
516
|
+
// goes through the standard retry / flood-wait / thread-fallback wrapper. A
|
|
517
|
+
// thrown send propagates so the sweep releases the claim and retries next
|
|
518
|
+
// tick (the record is never journaled → never lost).
|
|
519
|
+
const landed: Array<{ messageId: number; text: string }> = []
|
|
520
|
+
const chunks = splitMarkdownChunks(text)
|
|
521
|
+
for (let idx = 0; idx < chunks.length; idx++) {
|
|
522
|
+
const chunk = chunks[idx]
|
|
523
|
+
const isLast = idx === chunks.length - 1
|
|
435
524
|
const res = await retryWithThreadFallback(
|
|
436
525
|
deps.retry,
|
|
437
526
|
(tid) => {
|
|
@@ -439,13 +528,17 @@ export function createOutboxSend(deps: {
|
|
|
439
528
|
// Button on the LAST chunk only (final visible message).
|
|
440
529
|
const opts =
|
|
441
530
|
isLast && replyMarkup != null ? { ...base, reply_markup: replyMarkup } : base
|
|
442
|
-
return bot
|
|
531
|
+
return sendChunkRich(bot, chatId, chunk, opts)
|
|
443
532
|
},
|
|
444
533
|
{ threadId: threadId ?? undefined, chat_id: chatId, verb: 'outbox-sweep.sendMessage' },
|
|
445
534
|
)
|
|
446
|
-
|
|
535
|
+
const id = res?.message_id
|
|
536
|
+
if (id != null) landed.push({ messageId: id, text: chunk })
|
|
537
|
+
}
|
|
538
|
+
return {
|
|
539
|
+
messageId: landed.length > 0 ? landed[landed.length - 1]!.messageId : undefined,
|
|
540
|
+
chunks: landed,
|
|
447
541
|
}
|
|
448
|
-
return lastId
|
|
449
542
|
}
|
|
450
543
|
}
|
|
451
544
|
|
|
@@ -463,6 +556,10 @@ export function startOutboxSweep(deps: {
|
|
|
463
556
|
threadId: number | null,
|
|
464
557
|
text: string,
|
|
465
558
|
) => OutboxDeliveryMarkup | undefined
|
|
559
|
+
/** Persist a sweep-delivered answer to history — forwarded to {@link sweepOutbox}
|
|
560
|
+
* as {@link OutboxSweepDeps.recordOutbound}. Wired by the gateway to the shared
|
|
561
|
+
* `recordOutbound` (history.ts). Absent → no history row (legacy behaviour). */
|
|
562
|
+
recordOutbound?: OutboxSweepDeps['recordOutbound']
|
|
466
563
|
log?: (line: string) => void
|
|
467
564
|
/** Test seam — override the flood probe (default: the persisted breaker). */
|
|
468
565
|
floodWaitRemainingMs?: () => number
|
|
@@ -500,6 +597,7 @@ export function startOutboxSweep(deps: {
|
|
|
500
597
|
stateDir: deps.stateDir,
|
|
501
598
|
log: deps.log,
|
|
502
599
|
send,
|
|
600
|
+
...(deps.recordOutbound != null ? { recordOutbound: deps.recordOutbound } : {}),
|
|
503
601
|
floodWaitRemainingMs: floodProbe,
|
|
504
602
|
textAlreadyDelivered: (chatId, threadId, text) => deps.dedupCheck(chatId, threadId ?? undefined, text),
|
|
505
603
|
registryChainLookup: (taskId) => {
|
|
@@ -12,6 +12,7 @@ import type { SessionEvent } from '../session-tail.js'
|
|
|
12
12
|
import { isTelegramSurfaceTool } from '../tool-names.js'
|
|
13
13
|
import { toolLabel } from '../tool-labels.js'
|
|
14
14
|
import { applyBackgroundShellLiveness } from './background-shell-liveness.js'
|
|
15
|
+
import { removeCompactionMarker, resolveTelegramStateDir } from './compaction-marker.js'
|
|
15
16
|
|
|
16
17
|
/** The silence-poke module namespace (kept as `typeof` so no surface drift). */
|
|
17
18
|
type SilencePoke = typeof import('../silence-poke.js')
|
|
@@ -83,6 +84,18 @@ export function applySilencePokeSessionEvent(
|
|
|
83
84
|
if (ev.toolUseId != null && ev.toolUseId.length > 0) {
|
|
84
85
|
silencePoke.noteToolEnd(key, ev.toolUseId, Date.now())
|
|
85
86
|
}
|
|
87
|
+
} else if (ev.kind === 'compact_boundary') {
|
|
88
|
+
// #4058 — mid-turn compaction ENDED (the transcript's compact_boundary
|
|
89
|
+
// record only exists once compaction is done). Two effects:
|
|
90
|
+
// 1. Clear the PreCompact marker so the compaction defer stops holding
|
|
91
|
+
// the 300s fallback (a post-compaction wedge fires on schedule).
|
|
92
|
+
// 2. Count the boundary as PRODUCTION: the silence clock ran the whole
|
|
93
|
+
// compaction, so without a reset the fallback would fire on the very
|
|
94
|
+
// next tick — before the resumed model's first output can land. The
|
|
95
|
+
// transcript resuming IS observable progress; a turn that stays
|
|
96
|
+
// silent AFTER compaction still fires one full window later.
|
|
97
|
+
removeCompactionMarker(resolveTelegramStateDir())
|
|
98
|
+
silencePoke.noteProduction(key, Date.now())
|
|
86
99
|
}
|
|
87
100
|
// #3519 sharpen: feed background-shell liveness (ALIVE/DEAD) to silence-poke — see background-shell-liveness.ts.
|
|
88
101
|
applyBackgroundShellLiveness(silencePoke, key, ev)
|
|
@@ -2312,6 +2312,10 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
2312
2312
|
let sentIds: number[] = []
|
|
2313
2313
|
let chunkCount = 0
|
|
2314
2314
|
let delivered = false
|
|
2315
|
+
// Set by the send-ack claim callback below once the durable
|
|
2316
|
+
// delivered-keys journal has been written at ACK time — so the finally
|
|
2317
|
+
// block does not journal the same nonce a second time.
|
|
2318
|
+
let earlyJournaled = false
|
|
2315
2319
|
try {
|
|
2316
2320
|
// #3276 — the ONE delivery primitive. deliverAnswer routes through
|
|
2317
2321
|
// `sendReplyChunks` (the same send core executeReply uses) and posts
|
|
@@ -2330,6 +2334,33 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
2330
2334
|
// S4 — anchor the flushed answer to the inbound it answers
|
|
2331
2335
|
// (null for synthesized turns, which send bare as before).
|
|
2332
2336
|
replyToMessageId: turn.sourceMessageId,
|
|
2337
|
+
// Duplicate-message race fix — write the DURABLE delivered-keys
|
|
2338
|
+
// journal at SEND-ACK, BEFORE deliverAnswer's read-back probe (which
|
|
2339
|
+
// the cosmetic-edit flood-fuse can defer ~30s). Without this the
|
|
2340
|
+
// journal write happened only in the finally below, AFTER `await
|
|
2341
|
+
// deliverAnswer` returned, so the outbox sweep — which waits just
|
|
2342
|
+
// OUTBOX_QUIET_MS (5s) before checking `deliveredNonces` — saw no
|
|
2343
|
+
// entry and sent a SECOND copy of this answer. Journaling under the
|
|
2344
|
+
// SAME nonce (turn.turnId == the Stop-hook record's turnNonce) makes
|
|
2345
|
+
// the sweep skip-journaled and clears any captured record.
|
|
2346
|
+
onAckClaim: (ackIds) => {
|
|
2347
|
+
try {
|
|
2348
|
+
journalExternalDelivery(
|
|
2349
|
+
{
|
|
2350
|
+
turnNonce: turn.turnId,
|
|
2351
|
+
text: capturedText,
|
|
2352
|
+
tgMessageId: ackIds.length > 0 ? ackIds[0] : undefined,
|
|
2353
|
+
deliverySource: 'flush',
|
|
2354
|
+
},
|
|
2355
|
+
STATE_DIR,
|
|
2356
|
+
)
|
|
2357
|
+
earlyJournaled = true
|
|
2358
|
+
} catch (err) {
|
|
2359
|
+
process.stderr.write(
|
|
2360
|
+
`telegram gateway: turn-flush send-ack journal write failed (non-fatal): ${(err as Error).message}\n`,
|
|
2361
|
+
)
|
|
2362
|
+
}
|
|
2363
|
+
},
|
|
2333
2364
|
})
|
|
2334
2365
|
sentIds = delivery.sentIds
|
|
2335
2366
|
chunkCount = delivery.chunkCount
|
|
@@ -2433,7 +2464,14 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
2433
2464
|
// between this send and the next process's backstop. Journal ONLY on
|
|
2434
2465
|
// a receipt-gated `delivered` success. Best-effort: a journal-write
|
|
2435
2466
|
// failure must never demote the successful delivery.
|
|
2436
|
-
|
|
2467
|
+
//
|
|
2468
|
+
// Duplicate-message race fix: skip when the send-ack claim
|
|
2469
|
+
// (`onAckClaim`) already journaled this nonce at ACK time. That
|
|
2470
|
+
// path is the primary writer now (it runs before the read-back
|
|
2471
|
+
// probe); this finally write only covers the case where the claim
|
|
2472
|
+
// never fired — e.g. a card-only delivery where every chunk landed
|
|
2473
|
+
// but no fresh receipt gated the claim, yet `delivered` still held.
|
|
2474
|
+
if (delivered && !earlyJournaled) {
|
|
2437
2475
|
try {
|
|
2438
2476
|
journalExternalDelivery(
|
|
2439
2477
|
{
|