switchroom 0.18.27 → 0.18.29
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 +8 -1
- package/dist/auth-broker/index.js +0 -57
- package/dist/cli/switchroom.js +501 -497
- package/dist/host-control/main.js +1 -58
- package/dist/vault/approvals/kernel-server.js +0 -57
- package/dist/vault/broker/server.js +0 -57
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +37 -19
- package/telegram-plugin/dist/gateway/gateway.js +655 -587
- package/telegram-plugin/gateway/backstop-delivery.ts +272 -0
- package/telegram-plugin/gateway/forward-origin.ts +9 -1
- package/telegram-plugin/gateway/gateway.ts +511 -397
- package/telegram-plugin/gateway/model-command.ts +227 -602
- package/telegram-plugin/gateway/turn-record-status.ts +45 -0
- package/telegram-plugin/gateway/worker-pin-reaper.ts +54 -0
- package/telegram-plugin/history.ts +153 -23
- package/telegram-plugin/shared/local-time.ts +56 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +250 -0
- package/telegram-plugin/tests/forward-origin.test.ts +30 -3
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +86 -59
- package/telegram-plugin/tests/history.test.ts +88 -0
- package/telegram-plugin/tests/local-time.test.ts +68 -0
- package/telegram-plugin/tests/model-command.test.ts +317 -1535
- package/telegram-plugin/tests/turn-flush-safety.test.ts +34 -0
- package/telegram-plugin/tests/worker-feed-migration-eviction.test.ts +140 -0
- package/telegram-plugin/tests/worker-pin-reaper.test.ts +78 -0
- package/telegram-plugin/tier-downgrade.ts +4 -3
- package/telegram-plugin/turn-flush-safety.ts +25 -1
- package/telegram-plugin/worker-activity-feed.ts +78 -5
- package/vendor/hindsight-memory/scripts/lib/content.py +40 -6
- package/vendor/hindsight-memory/tests/test_content.py +28 -7
|
@@ -102,6 +102,7 @@ import {
|
|
|
102
102
|
forwardOriginDateIso,
|
|
103
103
|
type ForwardOriginInfo,
|
|
104
104
|
} from './forward-origin.js'
|
|
105
|
+
import { fmtLocalStamp, resolveEnvTimezone } from '../shared/local-time.js'
|
|
105
106
|
import { StatusReactionController } from '../status-reactions.js'
|
|
106
107
|
import { DeferredDoneReactions } from '../reaction-defer.js'
|
|
107
108
|
import { createWorkerActivityFeed, isWorkerActivityFeedEnabled } from '../worker-activity-feed.js'
|
|
@@ -473,7 +474,6 @@ import {
|
|
|
473
474
|
handleModelCommand,
|
|
474
475
|
buildModelMenu,
|
|
475
476
|
handleModelMenuCallback,
|
|
476
|
-
isSrToClaudeTransition,
|
|
477
477
|
isValidModelArg,
|
|
478
478
|
MODEL_CALLBACK_PREFIX,
|
|
479
479
|
MODEL_CALLBACK_HEADER,
|
|
@@ -505,7 +505,7 @@ import {
|
|
|
505
505
|
import { runTierDowngrade } from './tier-downgrade-wiring.js'
|
|
506
506
|
import { runPremiumRecoveryPing } from './premium-recovery-wiring.js'
|
|
507
507
|
import { decidePremiumRecovery } from '../premium-recovery.js'
|
|
508
|
-
import { discoverModels
|
|
508
|
+
import { discoverModels } from '../../src/agents/model-picker.js'
|
|
509
509
|
import { resolveMainModel, SWITCHROOM_DEFAULT_THINKING_EFFORT } from '../../src/agents/scaffold.js'
|
|
510
510
|
import {
|
|
511
511
|
parseEffortCommand,
|
|
@@ -604,6 +604,7 @@ import {
|
|
|
604
604
|
} from './queued-card-store.js'
|
|
605
605
|
import {
|
|
606
606
|
decideWorkerPinReaps,
|
|
607
|
+
storeOnlyWorkerPinCandidates,
|
|
607
608
|
WORKER_PIN_TTL_MS_DEFAULT,
|
|
608
609
|
} from './worker-pin-reaper.js'
|
|
609
610
|
import { driveEscalation } from './escalation-drive.js'
|
|
@@ -634,9 +635,13 @@ import { decideObligationTurnEnd } from './obligation-turn-end.js'
|
|
|
634
635
|
import { maybeRotate } from './turns-jsonl-rotate.js'
|
|
635
636
|
import {
|
|
636
637
|
buildTurnRecord,
|
|
637
|
-
|
|
638
|
+
finalizeBackstopSendGated,
|
|
638
639
|
type DeliveryOutcome,
|
|
639
640
|
} from './turn-record-status.js'
|
|
641
|
+
import {
|
|
642
|
+
BackstopDeliveryLedger,
|
|
643
|
+
runBackstopDelivery,
|
|
644
|
+
} from './backstop-delivery.js'
|
|
640
645
|
import {
|
|
641
646
|
createDeliveryQueue,
|
|
642
647
|
trackDelivery,
|
|
@@ -2337,6 +2342,150 @@ const outboundDedup = new OutboundDedupCache()
|
|
|
2337
2342
|
// catches the containment case the exact-text `outboundDedup` misses (a
|
|
2338
2343
|
// `narration\n\nanswer` flush never equals the clean `answer`-only reply).
|
|
2339
2344
|
const flushedTurnSupersede = new FlushedTurnSupersedeRegistry()
|
|
2345
|
+
// #3276 — the turn-flush backstop's per-turn delivery latch + per-chunk
|
|
2346
|
+
// idempotency ledger. The latch (keyed on `turnId`) is the deterministic
|
|
2347
|
+
// arbiter of backstop-vs-reply; the chunk ledger lets a retry after a partial
|
|
2348
|
+
// send resume at the first unsent chunk instead of re-sending chunk 0.
|
|
2349
|
+
const backstopDeliveryLedger = new BackstopDeliveryLedger()
|
|
2350
|
+
// #3276 finding-1 — bounded in-turn retries for the backstop send before it
|
|
2351
|
+
// gives up and records `send_failed` (leaving the obligation open for the
|
|
2352
|
+
// liveness floor). Resumes mid-chunk each attempt, so chunk 0 is never
|
|
2353
|
+
// re-sent. Env-tunable for ops; default 3.
|
|
2354
|
+
const BACKSTOP_DELIVERY_MAX_ATTEMPTS = (() => {
|
|
2355
|
+
const raw = Number(process.env.SWITCHROOM_BACKSTOP_DELIVERY_MAX_ATTEMPTS)
|
|
2356
|
+
return Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : 3
|
|
2357
|
+
})()
|
|
2358
|
+
|
|
2359
|
+
/**
|
|
2360
|
+
* #3276 — the ONE delivery primitive the turn-flush backstop uses to put a
|
|
2361
|
+
* flushed answer into the chat. It routes through `sendReplyChunks` — the SAME
|
|
2362
|
+
* battle-tested send core `executeReply` uses (THREAD_NOT_FOUND fallback,
|
|
2363
|
+
* length re-split, parse-reject plaintext fallback) — and returns the REAL
|
|
2364
|
+
* fresh chat message ids.
|
|
2365
|
+
*
|
|
2366
|
+
* Deliberately NOT card-coupled: it never edits the progress card, so a
|
|
2367
|
+
* "delivery" can never be a card mutation that the marker-sweep GC's ~60-90s
|
|
2368
|
+
* later. The card is unpinned/collapsed by the caller as a purely cosmetic
|
|
2369
|
+
* follow-up (guard 4). `previewMessageId` is always null here.
|
|
2370
|
+
*
|
|
2371
|
+
* Idempotent (guard 6): each chunk is sent under `backstopDeliveryLedger`. A
|
|
2372
|
+
* chunk that already landed for this `turnId` is skipped, and a pending marker
|
|
2373
|
+
* is written before every wire call, so a retry after a partial send or a lost
|
|
2374
|
+
* ack resumes at the first unsent chunk and never re-sends chunk 0.
|
|
2375
|
+
*
|
|
2376
|
+
* `text` must already be fully normalized/redacted/scrubbed by the caller (the
|
|
2377
|
+
* turn-flush branch runs the exact reply-parity pipeline before calling this).
|
|
2378
|
+
*/
|
|
2379
|
+
async function deliverAnswer(args: {
|
|
2380
|
+
chatId: string
|
|
2381
|
+
threadId: number | undefined
|
|
2382
|
+
text: string
|
|
2383
|
+
turnId: string
|
|
2384
|
+
cardMessageId: number | null
|
|
2385
|
+
}): Promise<{ sentIds: number[]; chunkCount: number; delivered: boolean; exhausted: boolean }> {
|
|
2386
|
+
const { chatId, turnId } = args
|
|
2387
|
+
// Inject visible blank-line spacers into `\n\n` gaps, then split — exactly as
|
|
2388
|
+
// executeReply does on the non-literal path (idempotent, one U+00A0 per gap).
|
|
2389
|
+
const rendered = addParagraphSpacers(args.text)
|
|
2390
|
+
const chunks = splitMarkdownChunks(rendered, RICH_MESSAGE_MAX_CHARS)
|
|
2391
|
+
|
|
2392
|
+
const deps: ReplyChunkSendDeps = {
|
|
2393
|
+
sendRich: (opts, body, tid) =>
|
|
2394
|
+
robustApiCall(
|
|
2395
|
+
// allow-raw-bot-api: deliverAnswer chunk-loop adapter — sendRichMessage routed through robustApiCall; THREAD_NOT_FOUND handled by sendReplyChunks' fallback ladder
|
|
2396
|
+
() => bot.api.sendRichMessage(chatId, body as never, opts as never),
|
|
2397
|
+
{ threadId: tid, chat_id: chatId, priorityClass: 'critical' },
|
|
2398
|
+
),
|
|
2399
|
+
sendLiteral: (opts, txt, tid) =>
|
|
2400
|
+
robustApiCall(
|
|
2401
|
+
// allow-raw-bot-api: deliverAnswer chunk-loop adapter — literal sendMessage routed through robustApiCall; THREAD_NOT_FOUND handled by sendReplyChunks
|
|
2402
|
+
() => bot.api.sendMessage(chatId, txt, opts as never),
|
|
2403
|
+
{ threadId: tid, chat_id: chatId, priorityClass: 'critical' },
|
|
2404
|
+
),
|
|
2405
|
+
// allow-raw-bot-api: literal last-resort fallback (parse-reject / length re-split); wrapping would re-enter the policy that just rejected the payload
|
|
2406
|
+
sendLiteralRaw: (opts, txt) => bot.api.sendMessage(chatId, txt, opts as never),
|
|
2407
|
+
// allow-raw-bot-api: rich length-error re-split last resort; wrapping would re-enter the chunk-loop classification on an already-classified length failure
|
|
2408
|
+
sendRichRaw: (opts, body) => bot.api.sendRichMessage(chatId, body as never, opts as never),
|
|
2409
|
+
editPreview: (mid, body, opts, tid) =>
|
|
2410
|
+
robustApiCall(
|
|
2411
|
+
// allow-raw-bot-api: preview edit-in-place routed through robustApiCall; thread fallback handled by sendReplyChunks
|
|
2412
|
+
() => bot.api.editMessageText(chatId, mid, body as never, opts as never),
|
|
2413
|
+
{ threadId: tid, chat_id: chatId, priorityClass: 'critical', messageId: mid, editPayload: body },
|
|
2414
|
+
),
|
|
2415
|
+
richMessage,
|
|
2416
|
+
logOutbound,
|
|
2417
|
+
// deliverAnswer never sets a previewMessageId, so this is never invoked;
|
|
2418
|
+
// provide a best-effort delete for interface completeness.
|
|
2419
|
+
deleteStalePreview: async (id: number): Promise<void> => {
|
|
2420
|
+
await swallowingApiCall(
|
|
2421
|
+
() => bot.api.deleteMessage(chatId, id),
|
|
2422
|
+
{ chat_id: chatId, verb: 'deliverAnswer.deleteStalePreview' },
|
|
2423
|
+
)
|
|
2424
|
+
},
|
|
2425
|
+
stderr: (s: string) => { process.stderr.write(s) },
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
// Send ONE chunk via the shared `sendReplyChunks` core. `liveThreadId`
|
|
2429
|
+
// threads the THREAD_NOT_FOUND fallback decision across chunks. Returns the
|
|
2430
|
+
// landed message id(s) (a length-resplit chunk may land >1); throws on an
|
|
2431
|
+
// unrecoverable send failure so the retry orchestrator can resume.
|
|
2432
|
+
let liveThreadId = args.threadId
|
|
2433
|
+
const sendChunk = async (_chunkIndex: number, text: string): Promise<number[]> => {
|
|
2434
|
+
const chunkIds: number[] = []
|
|
2435
|
+
const res = await sendReplyChunks(deps, {
|
|
2436
|
+
chatId,
|
|
2437
|
+
chunks: [text],
|
|
2438
|
+
literalText: false,
|
|
2439
|
+
suppressText: false,
|
|
2440
|
+
threadId: liveThreadId,
|
|
2441
|
+
previewMessageId: null,
|
|
2442
|
+
sentIds: chunkIds,
|
|
2443
|
+
buildSendOpts: (_i, _isLast, tid) => ({
|
|
2444
|
+
...(tid != null ? { message_thread_id: tid } : {}),
|
|
2445
|
+
link_preview_options: { is_disabled: true },
|
|
2446
|
+
}),
|
|
2447
|
+
buildPreviewEditOpts: () => ({}),
|
|
2448
|
+
})
|
|
2449
|
+
liveThreadId = res.threadId
|
|
2450
|
+
return chunkIds
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
// Bounded in-turn retry (finding-1 fix): resumes at the first unsent chunk on
|
|
2454
|
+
// each attempt via the ledger, so chunk 0 is delivered exactly once even
|
|
2455
|
+
// across retries. `delivered`/`exhausted` tell the caller whether the answer
|
|
2456
|
+
// actually reached the chat — the caller leaves the delivery obligation OPEN
|
|
2457
|
+
// on terminal failure so the liveness floor re-presents it.
|
|
2458
|
+
const result = await runBackstopDelivery(
|
|
2459
|
+
backstopDeliveryLedger,
|
|
2460
|
+
turnId,
|
|
2461
|
+
chunks,
|
|
2462
|
+
args.cardMessageId,
|
|
2463
|
+
{
|
|
2464
|
+
sendChunk,
|
|
2465
|
+
recordOutbound: HISTORY_ENABLED
|
|
2466
|
+
? (messageIds, texts) => {
|
|
2467
|
+
try {
|
|
2468
|
+
recordOutbound({
|
|
2469
|
+
chat_id: chatId,
|
|
2470
|
+
thread_id: args.threadId ?? null,
|
|
2471
|
+
message_ids: messageIds,
|
|
2472
|
+
texts,
|
|
2473
|
+
})
|
|
2474
|
+
} catch { /* best-effort */ }
|
|
2475
|
+
}
|
|
2476
|
+
: undefined,
|
|
2477
|
+
stderr: (s: string) => { process.stderr.write(s) },
|
|
2478
|
+
},
|
|
2479
|
+
BACKSTOP_DELIVERY_MAX_ATTEMPTS,
|
|
2480
|
+
)
|
|
2481
|
+
return {
|
|
2482
|
+
sentIds: result.sentIds,
|
|
2483
|
+
chunkCount: result.chunkCount,
|
|
2484
|
+
delivered: result.delivered,
|
|
2485
|
+
exhausted: result.exhausted,
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2340
2489
|
/**
|
|
2341
2490
|
* Per-chat cache of `available_reactions` from `getChat`. Populated lazily —
|
|
2342
2491
|
* the FIRST message in a chat creates a controller without the filter (null
|
|
@@ -5080,7 +5229,7 @@ function emitTurnRecord(turn: CurrentTurn, endedAt: number): void {
|
|
|
5080
5229
|
|
|
5081
5230
|
function endCurrentTurnAtomic(
|
|
5082
5231
|
turn: CurrentTurn,
|
|
5083
|
-
opts?: { deferRecord?: boolean },
|
|
5232
|
+
opts?: { deferRecord?: boolean; deferObligationClose?: boolean },
|
|
5084
5233
|
): number | null {
|
|
5085
5234
|
// PR-4e — keyed liveness + keyed clear (leak-close-at-origin). Flag-OFF: the
|
|
5086
5235
|
// guard is `currentTurn === turn` and the clear nulls the singleton, verbatim.
|
|
@@ -5149,7 +5298,13 @@ function endCurrentTurnAtomic(
|
|
|
5149
5298
|
// obligations are designed to catch ends via silence_fallback, NOT turn_end.
|
|
5150
5299
|
// At turn_end with replyCalled=true the model explicitly signalled completion
|
|
5151
5300
|
// AND replied, so the obligation is satisfied regardless of finalAnswerDelivered.
|
|
5152
|
-
|
|
5301
|
+
// #3276 finding 1 — the turn-flush backstop passes `deferObligationClose` so
|
|
5302
|
+
// the obligation disposition reflects the REAL send outcome (resolved in its
|
|
5303
|
+
// async finally after the bounded retry), NOT the speculative fire-time
|
|
5304
|
+
// `finalAnswerDelivered=true`. Closing here would satisfy the obligation
|
|
5305
|
+
// before the send is known to have landed, re-introducing the silent-drop on
|
|
5306
|
+
// terminal failure. Every synchronous turn-end path is unchanged.
|
|
5307
|
+
if (OBLIGATION_LEDGER_ENABLED && opts?.deferObligationClose !== true) {
|
|
5153
5308
|
if (decideObligationTurnEnd(turn.finalAnswerDelivered, turn.replyCalled) === 'close') {
|
|
5154
5309
|
obligationLedger.close(turn.turnId)
|
|
5155
5310
|
} else {
|
|
@@ -9023,16 +9178,32 @@ async function runMidSessionCardReaper(): Promise<void> {
|
|
|
9023
9178
|
// the durable store row clear together.
|
|
9024
9179
|
if (WORKER_PIN_REAPER_ENABLED && PIN_STATUS_WHILE_WORKING) {
|
|
9025
9180
|
try {
|
|
9026
|
-
const
|
|
9027
|
-
.filter((k) => k.startsWith('wk:'))
|
|
9028
|
-
|
|
9029
|
-
|
|
9030
|
-
|
|
9031
|
-
|
|
9032
|
-
|
|
9033
|
-
|
|
9034
|
-
|
|
9035
|
-
|
|
9181
|
+
const inMemoryKeys = new Set(
|
|
9182
|
+
[...statusPinState.keys()].filter((k) => k.startsWith('wk:')),
|
|
9183
|
+
)
|
|
9184
|
+
const inMemoryCandidates = [...inMemoryKeys].map((k) => ({
|
|
9185
|
+
pinKey: k,
|
|
9186
|
+
chatId: statusPinChatIds.get(k) ?? '',
|
|
9187
|
+
// A missing timestamp (should not happen — set on every claim)
|
|
9188
|
+
// degrades to "claimed just now": terminality can still reap it,
|
|
9189
|
+
// the TTL gate never can. Conservative, never a spurious unpin.
|
|
9190
|
+
pinnedAt: statusPinPinnedAt.get(k) ?? now,
|
|
9191
|
+
}))
|
|
9192
|
+
// #3001 durable group net: fold in `wk:` rows that live in the DURABLE
|
|
9193
|
+
// store but have NO in-memory claim — the divergence window where the
|
|
9194
|
+
// claim was lost but the Telegram pin + store row survive. These would
|
|
9195
|
+
// otherwise linger until the next boot cleanup; the reconciling sweep
|
|
9196
|
+
// recovers them mid-session too, group-safely (per-message unpin of a
|
|
9197
|
+
// bot-tracked row, NEVER an unpin-all, NEVER an untracked/human pin).
|
|
9198
|
+
const storeOnlyCandidates = statusPinPersistEnabled
|
|
9199
|
+
? storeOnlyWorkerPinCandidates({
|
|
9200
|
+
rows: loadStatusPins(STATUS_PIN_STORE_PATH, statusPinStoreFs),
|
|
9201
|
+
inMemoryPinKeys: inMemoryKeys,
|
|
9202
|
+
now,
|
|
9203
|
+
})
|
|
9204
|
+
: []
|
|
9205
|
+
const storeOnlyKeys = new Set(storeOnlyCandidates.map((c) => c.pinKey))
|
|
9206
|
+
const candidates = [...inMemoryCandidates, ...storeOnlyCandidates]
|
|
9036
9207
|
const reaps = decideWorkerPinReaps({
|
|
9037
9208
|
pins: candidates,
|
|
9038
9209
|
statusOf: (agentId) => {
|
|
@@ -9064,11 +9235,35 @@ async function runMidSessionCardReaper(): Promise<void> {
|
|
|
9064
9235
|
now,
|
|
9065
9236
|
})
|
|
9066
9237
|
for (const reap of reaps) {
|
|
9238
|
+
const storeOnly = storeOnlyKeys.has(reap.pinKey)
|
|
9067
9239
|
process.stderr.write(
|
|
9068
9240
|
`telegram gateway: worker-pin reaper unpinning ${reap.pinKey} ` +
|
|
9069
|
-
`(chat=${reap.chatId} reason=${reap.reason}
|
|
9241
|
+
`(chat=${reap.chatId} reason=${reap.reason}` +
|
|
9242
|
+
`${storeOnly ? ' source=store-orphan' : ''})\n`,
|
|
9070
9243
|
)
|
|
9071
|
-
|
|
9244
|
+
if (storeOnly && reap.messageId != null) {
|
|
9245
|
+
// No in-memory claim to reconcile: unpin the exact tracked message
|
|
9246
|
+
// (per-message — group-safe) and drop the store row directly. Both
|
|
9247
|
+
// are best-effort/idempotent; a failed unpin still drops the row so
|
|
9248
|
+
// the next boot's cleanup is the final backstop.
|
|
9249
|
+
try {
|
|
9250
|
+
await statusPinApi().unpinChatMessage(reap.chatId, reap.messageId)
|
|
9251
|
+
} catch (err) {
|
|
9252
|
+
process.stderr.write(
|
|
9253
|
+
`telegram gateway: worker-pin reaper store-orphan unpin failed ` +
|
|
9254
|
+
`(${reap.pinKey} chat=${reap.chatId} msg=${reap.messageId}): ` +
|
|
9255
|
+
`${(err as Error).message}\n`,
|
|
9256
|
+
)
|
|
9257
|
+
}
|
|
9258
|
+
await mutateStatusPinRow(
|
|
9259
|
+
STATUS_PIN_STORE_PATH,
|
|
9260
|
+
statusPinStoreFs,
|
|
9261
|
+
reap.pinKey,
|
|
9262
|
+
null,
|
|
9263
|
+
)
|
|
9264
|
+
} else {
|
|
9265
|
+
await reconcileStatusPin(reap.pinKey, reap.chatId, { pinned: false })
|
|
9266
|
+
}
|
|
9072
9267
|
}
|
|
9073
9268
|
} catch (err) {
|
|
9074
9269
|
process.stderr.write(
|
|
@@ -16178,7 +16373,10 @@ async function executeGetRecentMessages(args: Record<string, unknown>): Promise<
|
|
|
16178
16373
|
const summary = rows
|
|
16179
16374
|
.map(r => {
|
|
16180
16375
|
const who = r.role === 'user' ? r.user ?? 'user' : 'assistant'
|
|
16181
|
-
|
|
16376
|
+
// Local am/pm wall-clock (NOT UTC ISO) — this buffer is read straight
|
|
16377
|
+
// into the model's context via get_recent_messages, so every timestamp
|
|
16378
|
+
// it shows must be local to avoid competing with the local-time hint.
|
|
16379
|
+
const time = fmtLocalStamp(r.ts * 1000, resolveEnvTimezone())
|
|
16182
16380
|
const attach = r.attachment_kind ? ` [${r.attachment_kind}]` : ''
|
|
16183
16381
|
// Match server.ts get_recent_messages format exactly — both code paths
|
|
16184
16382
|
// serve the same MCP tool, so the agent's parsing must not depend on
|
|
@@ -18814,14 +19012,28 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
18814
19012
|
// post-fire pre-record window resolves this turn (via the unified owner
|
|
18815
19013
|
// resolver, reading the atom preserved in `recentTurnsById`) and
|
|
18816
19014
|
// suppresses itself against this latch — closing the residual race Part
|
|
18817
|
-
// 1's supersede cannot reach.
|
|
18818
|
-
// (the same ≥`FLUSH_SUBSTANTIVE_MIN_CHARS` floor the codebase uses to
|
|
18819
|
-
// recognise a real answer) so a short flush never latches against a
|
|
18820
|
-
// legitimate substantive reply. `capturedText` here is the selected,
|
|
19015
|
+
// 1's supersede cannot reach. `capturedText` here is the selected,
|
|
18821
19016
|
// normalized flush delivery text.
|
|
18822
|
-
|
|
18823
|
-
|
|
18824
|
-
|
|
19017
|
+
//
|
|
19018
|
+
// #3276 guard 2/5 — the arm is now UNCONDITIONAL (dropped the former
|
|
19019
|
+
// ≥`FLUSH_SUBSTANTIVE_MIN_CHARS` gate). A short terminal answer ("yes,
|
|
19020
|
+
// done") is a genuine answer this backstop is about to deliver, so a
|
|
19021
|
+
// late reply carrying the same short answer MUST supersede/suppress
|
|
19022
|
+
// rather than post a duplicate — the real-id supersede recorded below
|
|
19023
|
+
// corrects it in place.
|
|
19024
|
+
//
|
|
19025
|
+
// TWO distinct arbiters set synchronously here, before any `await`:
|
|
19026
|
+
// (a) `turn.answerDelivered` — the backstop-vs-LATE-REPLY signal the
|
|
19027
|
+
// reply path already reads (`decideAnswerLatchSuppression` +
|
|
19028
|
+
// `flushedTurnSupersede`), exactly as on `main`.
|
|
19029
|
+
// (b) `backstopDeliveryLedger.claim` — the backstop-vs-BACKSTOP
|
|
19030
|
+
// double-fire latch: `claim` returning false means this turn
|
|
19031
|
+
// already fired a backstop (answer-ready quiescence, then the
|
|
19032
|
+
// turn-end backstop), so this fire is a no-op. It does NOT
|
|
19033
|
+
// arbitrate the late reply (that is (a)); it is redundant-but-
|
|
19034
|
+
// cheap with the `currentTurn == null` bail below.
|
|
19035
|
+
turn.answerDelivered = true
|
|
19036
|
+
const backstopLatchClaimed = backstopDeliveryLedger.claim(turn.turnId)
|
|
18825
19037
|
|
|
18826
19038
|
// #654 deterministic double-message fix. Hand off the pinned
|
|
18827
19039
|
// progress card BEFORE state reset so the driver doesn't keep
|
|
@@ -18853,7 +19065,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
18853
19065
|
// bookkeeping, purge) still runs synchronously here for the #1067 /
|
|
18854
19066
|
// #1556 wedge-safety reasons. `backstopTurnEndedAt` is null iff the
|
|
18855
19067
|
// atom was already torn down elsewhere (no record to emit).
|
|
18856
|
-
const backstopTurnEndedAt = endCurrentTurnAtomic(turn, { deferRecord: true })
|
|
19068
|
+
const backstopTurnEndedAt = endCurrentTurnAtomic(turn, { deferRecord: true, deferObligationClose: true })
|
|
18857
19069
|
// #549 fix — turn-flush takes ownership of the captured-text
|
|
18858
19070
|
// backup; reset the preamble buffer (its content is already in
|
|
18859
19071
|
// the captured `capturedText`, which turn-flush is about to send).
|
|
@@ -18900,6 +19112,13 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
18900
19112
|
// finalAnswerDelivered). Not a failure.
|
|
18901
19113
|
if (backstopTurnEndedAt != null) {
|
|
18902
19114
|
turn.deliveryOutcome = 'suppressed'
|
|
19115
|
+
// #3276 finding 1 — obligation close was DEFERRED out of
|
|
19116
|
+
// endCurrentTurnAtomic. The reply tool already delivered this
|
|
19117
|
+
// turn's answer (recentCount>0), so resolve it as satisfied
|
|
19118
|
+
// here (idempotent with the reply path's own close). Without
|
|
19119
|
+
// this the deferred obligation would linger OPEN and spuriously
|
|
19120
|
+
// re-present a turn that WAS answered.
|
|
19121
|
+
if (OBLIGATION_LEDGER_ENABLED) obligationLedger.close(turn.turnId)
|
|
18903
19122
|
emitTurnRecord(turn, backstopTurnEndedAt)
|
|
18904
19123
|
}
|
|
18905
19124
|
return
|
|
@@ -18907,118 +19126,53 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
18907
19126
|
} catch {}
|
|
18908
19127
|
}
|
|
18909
19128
|
|
|
19129
|
+
// #3276 guard 5 — double-fire guard. If this turn already claimed the
|
|
19130
|
+
// delivery latch (a prior backstop fire — e.g. answer-ready quiescence
|
|
19131
|
+
// followed by the turn-end backstop for the same turn), do NOT deliver
|
|
19132
|
+
// again. The first fire owns delivery; this fire is a cosmetic no-op.
|
|
19133
|
+
if (!backstopLatchClaimed) {
|
|
19134
|
+
process.stderr.write(
|
|
19135
|
+
`telegram gateway: turn-flush skipped — turn ${turn.turnId} already claimed the delivery latch\n`,
|
|
19136
|
+
)
|
|
19137
|
+
return
|
|
19138
|
+
}
|
|
19139
|
+
|
|
18910
19140
|
process.stderr.write(
|
|
18911
19141
|
`telegram gateway: turn-flush firing — ${capturedText.length} chars without reply tool ` +
|
|
18912
19142
|
`(chat=${backstopChatId} cardMsgId=${backstopCardMessageId ?? 'none'})\n`,
|
|
18913
19143
|
)
|
|
18914
|
-
|
|
18915
|
-
|
|
18916
|
-
|
|
18917
|
-
|
|
18918
|
-
|
|
18919
|
-
//
|
|
18920
|
-
//
|
|
18921
|
-
|
|
18922
|
-
|
|
18923
|
-
|
|
18924
|
-
let htmlChunks: string[] = []
|
|
18925
|
-
const sentIds: number[] = []
|
|
18926
|
-
// track whether the send threw so the deferred record reflects the
|
|
18927
|
-
// real outcome (throw OR partial multi-chunk → send_failed).
|
|
18928
|
-
let sendThrew = false
|
|
19144
|
+
// PR B (Fix 1) — send accounting declared OUTSIDE the try so the
|
|
19145
|
+
// single-record `finally` below reads it on EVERY in-process exit.
|
|
19146
|
+
// `delivered` is the RECEIPT-gated truth (>=1 fresh non-card id AND
|
|
19147
|
+
// all chunks landed), computed by the retry orchestrator — NOT a
|
|
19148
|
+
// blanket outer-catch flag, so a throw in the post-delivery
|
|
19149
|
+
// bookkeeping below (dedup / supersede record) can never demote a
|
|
19150
|
+
// genuinely delivered turn to `send_failed` (finding 4).
|
|
19151
|
+
let sentIds: number[] = []
|
|
19152
|
+
let chunkCount = 0
|
|
19153
|
+
let delivered = false
|
|
18929
19154
|
try {
|
|
18930
|
-
// #
|
|
18931
|
-
//
|
|
18932
|
-
//
|
|
18933
|
-
//
|
|
18934
|
-
// .
|
|
18935
|
-
//
|
|
18936
|
-
//
|
|
18937
|
-
|
|
18938
|
-
|
|
18939
|
-
|
|
18940
|
-
|
|
18941
|
-
|
|
18942
|
-
|
|
18943
|
-
|
|
18944
|
-
|
|
18945
|
-
|
|
18946
|
-
|
|
18947
|
-
|
|
18948
|
-
|
|
18949
|
-
// #
|
|
18950
|
-
//
|
|
18951
|
-
// target message id implies a thread), so a stale-thread
|
|
18952
|
-
// 400 just fails the edit and the loop falls back to fresh
|
|
18953
|
-
// sendMessage. sendMessage IS thread-id-bearing — drop the
|
|
18954
|
-
// thread on THREAD_NOT_FOUND so the captured prose still
|
|
18955
|
-
// lands somewhere instead of being lost entirely.
|
|
18956
|
-
let firstSendUsedEdit = false
|
|
18957
|
-
let liveThreadId: number | undefined = backstopThreadId
|
|
18958
|
-
if (backstopCardMessageId != null && htmlChunks.length > 0) {
|
|
18959
|
-
try {
|
|
18960
|
-
await robustApiCall(
|
|
18961
|
-
() =>
|
|
18962
|
-
bot.api.editMessageText(
|
|
18963
|
-
backstopChatId,
|
|
18964
|
-
backstopCardMessageId,
|
|
18965
|
-
richMessage(htmlChunks[0]),
|
|
18966
|
-
sendOpts,
|
|
18967
|
-
),
|
|
18968
|
-
{
|
|
18969
|
-
chat_id: backstopChatId,
|
|
18970
|
-
verb: 'turn-flush.editMessageText',
|
|
18971
|
-
...(liveThreadId != null ? { threadId: liveThreadId } : {}),
|
|
18972
|
-
},
|
|
18973
|
-
)
|
|
18974
|
-
sentIds.push(backstopCardMessageId)
|
|
18975
|
-
firstSendUsedEdit = true
|
|
18976
|
-
} catch (err) {
|
|
18977
|
-
process.stderr.write(
|
|
18978
|
-
`telegram gateway: turn-flush card-takeover edit failed: ${(err as Error).message} — falling back to sendMessage\n`,
|
|
18979
|
-
)
|
|
18980
|
-
if (err instanceof Error && err.message === 'THREAD_NOT_FOUND') {
|
|
18981
|
-
liveThreadId = undefined
|
|
18982
|
-
}
|
|
18983
|
-
}
|
|
18984
|
-
}
|
|
18985
|
-
const remainingChunks = firstSendUsedEdit ? htmlChunks.slice(1) : htmlChunks
|
|
18986
|
-
for (const c of remainingChunks) {
|
|
18987
|
-
const sent = await retryWithThreadFallback(
|
|
18988
|
-
robustApiCall,
|
|
18989
|
-
(tid) => {
|
|
18990
|
-
const opts = {
|
|
18991
|
-
link_preview_options: { is_disabled: true },
|
|
18992
|
-
...(tid != null ? { message_thread_id: tid } : {}),
|
|
18993
|
-
}
|
|
18994
|
-
return bot.api.sendRichMessage(backstopChatId, richMessage(c), opts)
|
|
18995
|
-
},
|
|
18996
|
-
{
|
|
18997
|
-
threadId: liveThreadId,
|
|
18998
|
-
chat_id: backstopChatId,
|
|
18999
|
-
verb: 'turn-flush.sendMessage',
|
|
19000
|
-
},
|
|
19001
|
-
)
|
|
19002
|
-
if (liveThreadId != null) {
|
|
19003
|
-
const sentMsg = sent as { message_thread_id?: number }
|
|
19004
|
-
if (sentMsg.message_thread_id == null) liveThreadId = undefined
|
|
19005
|
-
}
|
|
19006
|
-
sentIds.push(sent.message_id)
|
|
19007
|
-
}
|
|
19008
|
-
if (HISTORY_ENABLED && sentIds.length > 0) {
|
|
19009
|
-
try {
|
|
19010
|
-
recordOutbound({
|
|
19011
|
-
chat_id: backstopChatId,
|
|
19012
|
-
thread_id: backstopThreadId ?? null,
|
|
19013
|
-
message_ids: sentIds,
|
|
19014
|
-
texts: htmlChunks,
|
|
19015
|
-
})
|
|
19016
|
-
} catch {}
|
|
19017
|
-
}
|
|
19018
|
-
// #546 dedup: record what turn-flush just sent so a
|
|
19019
|
-
// late-arriving reply / stream_reply with the same
|
|
19020
|
-
// content gets suppressed (claude-code retries the
|
|
19021
|
-
// un-acked tool_call after a bridge reconnect).
|
|
19155
|
+
// #3276 — the ONE delivery primitive. deliverAnswer routes through
|
|
19156
|
+
// `sendReplyChunks` (the same send core executeReply uses) and posts
|
|
19157
|
+
// a FRESH chat message; it NEVER edits the progress card, so a
|
|
19158
|
+
// "delivery" can never be a card mutation the marker-sweep GC's
|
|
19159
|
+
// ~60-90s later. It returns the REAL fresh chat message ids and
|
|
19160
|
+
// retries mid-chunk (bounded) before giving up — the per-chunk
|
|
19161
|
+
// ledger resumes at the first unsent chunk, never re-sending chunk 0
|
|
19162
|
+
// (guard 6).
|
|
19163
|
+
const delivery = await deliverAnswer({
|
|
19164
|
+
chatId: backstopChatId,
|
|
19165
|
+
threadId: backstopThreadId,
|
|
19166
|
+
text: capturedText,
|
|
19167
|
+
turnId: turn.turnId,
|
|
19168
|
+
cardMessageId: backstopCardMessageId,
|
|
19169
|
+
})
|
|
19170
|
+
sentIds = delivery.sentIds
|
|
19171
|
+
chunkCount = delivery.chunkCount
|
|
19172
|
+
delivered = delivery.delivered
|
|
19173
|
+
|
|
19174
|
+
// #546 dedup: record what turn-flush just sent so a late-arriving
|
|
19175
|
+
// reply / stream_reply with the same content gets suppressed.
|
|
19022
19176
|
outboundDedup.record(
|
|
19023
19177
|
backstopChatId,
|
|
19024
19178
|
backstopThreadId,
|
|
@@ -19026,14 +19180,10 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
19026
19180
|
Date.now(),
|
|
19027
19181
|
currentTurn?.registryKey ?? null,
|
|
19028
19182
|
)
|
|
19029
|
-
//
|
|
19030
|
-
//
|
|
19031
|
-
//
|
|
19032
|
-
//
|
|
19033
|
-
// the top of this branch (endCurrentTurnAtomic nulled currentTurn,
|
|
19034
|
-
// but the captured `turn` still carries the honest turnId). Covers
|
|
19035
|
-
// BOTH flush paths — answer-ready quiescence and the turn-end
|
|
19036
|
-
// backstop both funnel through this single IIFE.
|
|
19183
|
+
// #3276 guard 3 — feed the REAL fresh chat ids into the supersede
|
|
19184
|
+
// record so a late `reply` for the same turn corrects them in place
|
|
19185
|
+
// (edit / delete+resend) instead of shipping a second bubble. These
|
|
19186
|
+
// are genuine chat message ids now, never a card-edit id.
|
|
19037
19187
|
if (sentIds.length > 0) {
|
|
19038
19188
|
flushedTurnSupersede.record(
|
|
19039
19189
|
backstopChatId,
|
|
@@ -19042,13 +19192,25 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
19042
19192
|
Date.now(),
|
|
19043
19193
|
)
|
|
19044
19194
|
}
|
|
19045
|
-
// #
|
|
19046
|
-
//
|
|
19047
|
-
|
|
19048
|
-
//
|
|
19049
|
-
//
|
|
19050
|
-
//
|
|
19051
|
-
|
|
19195
|
+
// #3276 guard 4 — collapse the taken-over card to a NON-answer
|
|
19196
|
+
// state. The answer flowed ONLY through deliverAnswer (a fresh
|
|
19197
|
+
// bubble); here we just unpin/complete the card so no orphaned
|
|
19198
|
+
// ⚙️ Working… lingers. The card carries NO answer text, so a
|
|
19199
|
+
// card-collapse failure and a fresh-send failure can never leave
|
|
19200
|
+
// BOTH an answer-card AND an answer-bubble visible.
|
|
19201
|
+
if (!delivered) {
|
|
19202
|
+
// Retries exhausted with nothing durable delivered — finalize the
|
|
19203
|
+
// reaction as error and reset the latch so a genuine late reply is
|
|
19204
|
+
// NOT suppressed.
|
|
19205
|
+
if (backstopCtrl) backstopCtrl.finalize('error')
|
|
19206
|
+
backstopDeliveryLedger.release(turn.turnId)
|
|
19207
|
+
turn.answerDelivered = false
|
|
19208
|
+
} else if (backstopCtrl) {
|
|
19209
|
+
backstopCtrl.finalize('done')
|
|
19210
|
+
}
|
|
19211
|
+
// Unpin the card either way (cosmetic). completeTurn cleans up
|
|
19212
|
+
// pinMgr's per-turn state and unpins; fall back to the legacy
|
|
19213
|
+
// unpinForChat sweep when we didn't take over a turn.
|
|
19052
19214
|
if (backstopCardTurnKey != null) {
|
|
19053
19215
|
completeProgressCardTurn?.({
|
|
19054
19216
|
chatId: backstopChatId,
|
|
@@ -19059,49 +19221,50 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
19059
19221
|
unpinProgressCardForChat?.(backstopChatId, backstopThreadId)
|
|
19060
19222
|
}
|
|
19061
19223
|
} catch (err) {
|
|
19062
|
-
|
|
19063
|
-
|
|
19064
|
-
//
|
|
19065
|
-
//
|
|
19066
|
-
//
|
|
19067
|
-
|
|
19068
|
-
|
|
19069
|
-
|
|
19070
|
-
|
|
19071
|
-
|
|
19072
|
-
|
|
19073
|
-
// deletes the partial message A and the reply delivers cleanly —
|
|
19074
|
-
// resetting here is harmless in that case too.
|
|
19075
|
-
// FOLLOW-UP (coordinator to file an issue): a reply suppressed
|
|
19076
|
-
// synchronously DURING the in-flight send that then fails is not
|
|
19077
|
-
// fully closable with a boolean latch — a residual micro-window the
|
|
19078
|
-
// supersede+latch pair cannot eliminate. Not addressed in this PR.
|
|
19079
|
-
turn.answerDelivered = false
|
|
19080
|
-
// #1713: backstop send failed — finalize as error so the
|
|
19081
|
-
// turn ends cleanly with 😱 rather than leaving it open.
|
|
19082
|
-
if (backstopCtrl) backstopCtrl.finalize('error')
|
|
19224
|
+
// Only reachable via a throw in the post-delivery bookkeeping (the
|
|
19225
|
+
// delivery itself is retry-wrapped inside deliverAnswer and never
|
|
19226
|
+
// throws out). `delivered` already reflects the receipt-gated truth;
|
|
19227
|
+
// do NOT flip it here (finding 4). If nothing landed, reset the
|
|
19228
|
+
// latch so a genuine late reply is not suppressed.
|
|
19229
|
+
process.stderr.write(`telegram gateway: turn-flush post-delivery bookkeeping failed: ${(err as Error).message}\n`)
|
|
19230
|
+
if (!delivered) {
|
|
19231
|
+
turn.answerDelivered = false
|
|
19232
|
+
backstopDeliveryLedger.release(turn.turnId)
|
|
19233
|
+
if (backstopCtrl) backstopCtrl.finalize('error')
|
|
19234
|
+
}
|
|
19083
19235
|
} finally {
|
|
19084
|
-
//
|
|
19085
|
-
//
|
|
19086
|
-
//
|
|
19087
|
-
//
|
|
19088
|
-
//
|
|
19089
|
-
// (
|
|
19090
|
-
//
|
|
19091
|
-
//
|
|
19092
|
-
//
|
|
19093
|
-
//
|
|
19094
|
-
//
|
|
19095
|
-
// above already emitted its record and `return`ed BEFORE reaching
|
|
19096
|
-
// this try, so it can never double-write with this finally.
|
|
19236
|
+
// #3276 guard 7 + finding 1 — honest record AND honest recovery.
|
|
19237
|
+
// Status is derived from the RECEIPT gate: `complete` IFF a fresh
|
|
19238
|
+
// non-card id landed for every chunk; otherwise `send_failed`.
|
|
19239
|
+
//
|
|
19240
|
+
// The delivery-obligation close was DEFERRED out of
|
|
19241
|
+
// endCurrentTurnAtomic (deferObligationClose) so it reflects the
|
|
19242
|
+
// REAL send outcome here, not the speculative fire-time flag:
|
|
19243
|
+
// delivered → close the obligation (answered).
|
|
19244
|
+
// NOT deliv. → leave it OPEN + noteTurnEnded, so the ~150s
|
|
19245
|
+
// liveness floor re-presents the answer instead of
|
|
19246
|
+
// the old silent `send_failed` drop.
|
|
19097
19247
|
if (backstopTurnEndedAt != null) {
|
|
19098
|
-
|
|
19099
|
-
threw:
|
|
19100
|
-
|
|
19101
|
-
chunkCount
|
|
19248
|
+
finalizeBackstopSendGated(turn, {
|
|
19249
|
+
threw: !delivered,
|
|
19250
|
+
sentIds,
|
|
19251
|
+
chunkCount,
|
|
19252
|
+
cardMessageId: backstopCardMessageId,
|
|
19102
19253
|
})
|
|
19254
|
+
if (OBLIGATION_LEDGER_ENABLED) {
|
|
19255
|
+
if (delivered) {
|
|
19256
|
+
obligationLedger.close(turn.turnId)
|
|
19257
|
+
} else {
|
|
19258
|
+
// Terminal fail — do NOT mark the obligation satisfied.
|
|
19259
|
+
turn.finalAnswerDelivered = false
|
|
19260
|
+
obligationLedger.noteTurnEnded(turn.turnId, Date.now())
|
|
19261
|
+
}
|
|
19262
|
+
}
|
|
19103
19263
|
emitTurnRecord(turn, backstopTurnEndedAt)
|
|
19104
19264
|
}
|
|
19265
|
+
// GC the ledger only now — after success OR retry exhaustion — so a
|
|
19266
|
+
// resume could always read prior progress up to this point.
|
|
19267
|
+
backstopDeliveryLedger.clear(turn.turnId)
|
|
19105
19268
|
}
|
|
19106
19269
|
// #2094 cosmetic: the trailing `finally { purgeReactionTracking() }`
|
|
19107
19270
|
// was removed. endCurrentTurnAtomic already ran the canonical purge
|
|
@@ -21174,7 +21337,11 @@ async function handleInbound(
|
|
|
21174
21337
|
...(msgId != null ? { message_id: String(msgId) } : {}),
|
|
21175
21338
|
user: displayUser,
|
|
21176
21339
|
user_id: String(from.id),
|
|
21177
|
-
ts
|
|
21340
|
+
// Model-facing `ts="…"` on the inbound <channel> tag. Rendered as the
|
|
21341
|
+
// agent's LOCAL am/pm wall-clock (NOT UTC ISO) so the model never reads
|
|
21342
|
+
// a competing UTC "now" — the numeric epoch survives on InboundMessage.ts
|
|
21343
|
+
// (above) and in the SQLite history for any machine consumer.
|
|
21344
|
+
ts: fmtLocalStamp((ctx.message?.date ?? 0) * 1000, resolveEnvTimezone()),
|
|
21178
21345
|
...(messageThreadId != null ? { message_thread_id: String(messageThreadId) } : {}),
|
|
21179
21346
|
// Component 3 — origin turn id. The model is told to pass this back
|
|
21180
21347
|
// as origin_turn_id on the reply so the answer routes to the topic
|
|
@@ -23275,7 +23442,6 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
|
|
|
23275
23442
|
return []
|
|
23276
23443
|
}
|
|
23277
23444
|
},
|
|
23278
|
-
select: (a, label) => selectModel(a, label),
|
|
23279
23445
|
isBusy: () => currentTurn !== null,
|
|
23280
23446
|
getAgentName: getMyAgentName,
|
|
23281
23447
|
getQuotaBrief: async () => {
|
|
@@ -23294,7 +23460,6 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
|
|
|
23294
23460
|
} catch { /* quota is garnish — never block the menu on it */ }
|
|
23295
23461
|
return null
|
|
23296
23462
|
},
|
|
23297
|
-
inject: injectSlashCommandImpl,
|
|
23298
23463
|
getConfiguredModel: () => {
|
|
23299
23464
|
type AgentListResp = { agents: Array<{ name: string; model?: string | null }> }
|
|
23300
23465
|
const data = switchroomExecJson<AgentListResp>(['agent', 'list'])
|
|
@@ -23302,7 +23467,6 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
|
|
|
23302
23467
|
},
|
|
23303
23468
|
escapeHtml: escapeHtmlForTg,
|
|
23304
23469
|
preBlock,
|
|
23305
|
-
getActiveSessionModel: () => sessionModelSource.getOverride(),
|
|
23306
23470
|
/**
|
|
23307
23471
|
* Graceful restart for sr-* → Claude model switch. Same mechanism as
|
|
23308
23472
|
* the /restart command: writes a restart marker (so the post-restart
|
|
@@ -23388,6 +23552,18 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
|
|
|
23388
23552
|
resolveMainModel(deps.getConfiguredModel() ?? undefined),
|
|
23389
23553
|
)
|
|
23390
23554
|
sessionModelSource.setOverride(model)
|
|
23555
|
+
// Diagnosability (rev 5): the applied-model is now always greppable at the
|
|
23556
|
+
// relaunch boundary — `grep 'gw /model relaunch scheduled'` — closing the
|
|
23557
|
+
// gap the debug worker flagged (the retired inject path logged nothing).
|
|
23558
|
+
process.stderr.write(
|
|
23559
|
+
`telegram gateway: gw /model relaunch scheduled agent=${getMyAgentName()} token=${model} reason=${JSON.stringify(reason)}\n`,
|
|
23560
|
+
)
|
|
23561
|
+
// A manual /model switch clears any pending premium-recovery marker so the
|
|
23562
|
+
// "available again" ping can't still fire after the operator switched away
|
|
23563
|
+
// themselves. Rev 5: this moves here (from the deleted recordTypedModelSwitch /
|
|
23564
|
+
// recordModelMenuSideEffects) so it fires for EVERY switch path uniformly —
|
|
23565
|
+
// both R5 sites collapse to this single call.
|
|
23566
|
+
clearPremiumRecoveryOnManualSwitch(model)
|
|
23391
23567
|
try {
|
|
23392
23568
|
await deps.scheduleRestart(reason)
|
|
23393
23569
|
} catch (err) {
|
|
@@ -23404,6 +23580,36 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
|
|
|
23404
23580
|
throw err
|
|
23405
23581
|
}
|
|
23406
23582
|
},
|
|
23583
|
+
/**
|
|
23584
|
+
* `/model default` (rev 5): CLEAR the consume-once carrier + the in-memory
|
|
23585
|
+
* override, then relaunch so the LIVE session reverts to the configured
|
|
23586
|
+
* default. Mirrors scheduleModelRelaunch's rollback discipline (G1): on a
|
|
23587
|
+
* `restart_in_flight` throw keep the cleared state (the in-flight boot has no
|
|
23588
|
+
* carrier and reverts anyway); on any other dispatch failure restore the
|
|
23589
|
+
* prior carrier + override so a failed default-revert doesn't strand the
|
|
23590
|
+
* session in a half-cleared state.
|
|
23591
|
+
*/
|
|
23592
|
+
scheduleModelDefaultRelaunch: async (reason: string) => {
|
|
23593
|
+
const agentDir = resolveAgentDirFromEnv()
|
|
23594
|
+
if (!agentDir) throw new Error('agent dir unresolvable — cannot clear session-model file')
|
|
23595
|
+
const prevOverride = sessionModelSource.getOverride()
|
|
23596
|
+
const prevFileRaw = readSessionModelFileRaw(agentDir)
|
|
23597
|
+
clearSessionModelFile(agentDir)
|
|
23598
|
+
sessionModelSource.setOverride(null)
|
|
23599
|
+
process.stderr.write(
|
|
23600
|
+
`telegram gateway: gw /model relaunch scheduled agent=${getMyAgentName()} token=(default) reason=${JSON.stringify(reason)}\n`,
|
|
23601
|
+
)
|
|
23602
|
+
clearPremiumRecoveryOnManualSwitch(null)
|
|
23603
|
+
try {
|
|
23604
|
+
await deps.scheduleRestart(reason)
|
|
23605
|
+
} catch (err) {
|
|
23606
|
+
if ((err as { code?: string })?.code !== 'restart_in_flight') {
|
|
23607
|
+
restoreSessionModelFileRaw(agentDir, prevFileRaw)
|
|
23608
|
+
sessionModelSource.setOverride(prevOverride)
|
|
23609
|
+
}
|
|
23610
|
+
throw err
|
|
23611
|
+
}
|
|
23612
|
+
},
|
|
23407
23613
|
}
|
|
23408
23614
|
return deps
|
|
23409
23615
|
}
|
|
@@ -23418,125 +23624,14 @@ function modelMenuReplyMarkup(reply: ModelMenuReply): InlineKeyboard | undefined
|
|
|
23418
23624
|
return kb
|
|
23419
23625
|
}
|
|
23420
23626
|
|
|
23421
|
-
|
|
23422
|
-
|
|
23423
|
-
|
|
23424
|
-
|
|
23425
|
-
|
|
23426
|
-
|
|
23427
|
-
|
|
23428
|
-
|
|
23429
|
-
* `.session-model` carrier — it applies in-session and the explicit
|
|
23430
|
-
* `claude --model <configured>` flag reverts it on the next boot, so it lasts
|
|
23431
|
-
* exactly until the next restart with no durable state. (sr-* switches never
|
|
23432
|
-
* reach here — they go through scheduleModelRelaunch, which owns the
|
|
23433
|
-
* consume-once carrier.) The `/status` honesty invariant lives here: only
|
|
23434
|
-
* `reply.selectedModel` records; an unverified inject records nothing.
|
|
23435
|
-
* `/model default` clears any in-memory override and any leftover carrier.
|
|
23436
|
-
*/
|
|
23437
|
-
function recordTypedModelSwitch(
|
|
23438
|
-
reply: { text: string; selectedModel?: string },
|
|
23439
|
-
requestedModelArg: string | null,
|
|
23440
|
-
_deps: ModelCommandDeps,
|
|
23441
|
-
): string {
|
|
23442
|
-
const requested = requestedModelArg != null ? expandSrAlias(requestedModelArg) : null
|
|
23443
|
-
if (requested?.toLowerCase() === 'default') {
|
|
23444
|
-
const smDir = resolveAgentDirFromEnv()
|
|
23445
|
-
if (smDir) clearSessionModelFile(smDir)
|
|
23446
|
-
if (reply.selectedModel) sessionModelSource.setOverride(null)
|
|
23447
|
-
return ''
|
|
23448
|
-
}
|
|
23449
|
-
if (!reply.selectedModel) return ''
|
|
23450
|
-
sessionModelSource.setOverride(reply.selectedModel)
|
|
23451
|
-
// A manual /model apply to the dropped premium clears any pending recovery
|
|
23452
|
-
// marker so the "available again" ping can't still fire after the user has
|
|
23453
|
-
// already switched back themselves.
|
|
23454
|
-
clearPremiumRecoveryOnManualSwitch(reply.selectedModel)
|
|
23455
|
-
return ''
|
|
23456
|
-
}
|
|
23457
|
-
|
|
23458
|
-
/**
|
|
23459
|
-
* Record a model-MENU callback outcome (set the live in-memory override, clear
|
|
23460
|
-
* a leftover carrier on a Default tap) and drive an sr-*→Claude graceful
|
|
23461
|
-
* restart when the tap crosses that boundary — the only menu path that writes a
|
|
23462
|
-
* consume-once `.session-model` carrier (a live Claude tap writes none, rev 4).
|
|
23463
|
-
* Extracted from the live `mdl:*` dispatcher so the deferred (queued mid-turn)
|
|
23464
|
-
* apply records + restarts identically. Does NOT edit any Telegram message —
|
|
23465
|
-
* callers own the card edit. Returns a restart notice when a session restart
|
|
23466
|
-
* was scheduled (the card should then drop its keyboard).
|
|
23467
|
-
*/
|
|
23468
|
-
function recordModelMenuSideEffects(
|
|
23469
|
-
outcome: Awaited<ReturnType<typeof handleModelMenuCallback>>,
|
|
23470
|
-
modelDeps: ModelCommandDeps,
|
|
23471
|
-
cbChatId: string,
|
|
23472
|
-
cbThreadId: number | undefined,
|
|
23473
|
-
prevSessionModel: string | null,
|
|
23474
|
-
): { restartNotice?: string } {
|
|
23475
|
-
// Record a successful session switch so /status reflects what's actually
|
|
23476
|
-
// running. Session-scoped (rev 4): a live Claude menu tap writes NO
|
|
23477
|
-
// `.session-model` carrier — it applies in-session (native picker) and
|
|
23478
|
-
// reverts on the next boot. Only the sr→Claude transition below (which
|
|
23479
|
-
// relaunches) writes the consume-once carrier. A confirmed "Default
|
|
23480
|
-
// (recommended)" selection clears any leftover carrier.
|
|
23481
|
-
if (outcome.selectedModel) {
|
|
23482
|
-
sessionModelSource.setOverride(outcome.selectedModel)
|
|
23483
|
-
// Clear a pending premium-recovery marker when the tap re-selects the
|
|
23484
|
-
// dropped premium (menu OR the recovery ping's own switch-back button):
|
|
23485
|
-
// no stale "available again" ping once we're back on it. Idempotent — the
|
|
23486
|
-
// ping-send path already consumed the marker, so this is a no-op there.
|
|
23487
|
-
clearPremiumRecoveryOnManualSwitch(outcome.selectedModel)
|
|
23488
|
-
}
|
|
23489
|
-
if (outcome.clearedDefault) {
|
|
23490
|
-
const smDir = resolveAgentDirFromEnv()
|
|
23491
|
-
if (smDir) clearSessionModelFile(smDir)
|
|
23492
|
-
}
|
|
23493
|
-
|
|
23494
|
-
// sr-* → Claude transition: the picker-select only changes the session model
|
|
23495
|
-
// label, but the sr-* LiteLLM routing context persists until the session is
|
|
23496
|
-
// torn down — a graceful restart (same mechanism as /restart) is required.
|
|
23497
|
-
if (outcome.selectedModel && isSrToClaudeTransition(prevSessionModel, outcome.selectedModel)) {
|
|
23498
|
-
const agentName = getMyAgentName()
|
|
23499
|
-
// Carry the requested Claude model across the restart via the SAME
|
|
23500
|
-
// consume-once `.session-model` carrier a Claude → sr-* switch uses —
|
|
23501
|
-
// otherwise this transition's apply-relaunch boots the CONFIGURED default
|
|
23502
|
-
// and the tapped model is silently dropped. Applied on that one boot,
|
|
23503
|
-
// then reverts on the next restart (rev 4).
|
|
23504
|
-
const agentDir = resolveAgentDirFromEnv()
|
|
23505
|
-
const token = outcome.selectedModelToken
|
|
23506
|
-
if (agentDir && token) {
|
|
23507
|
-
try {
|
|
23508
|
-
writeSessionModelFile(
|
|
23509
|
-
agentDir,
|
|
23510
|
-
token,
|
|
23511
|
-
readConfiguredDefaultModel(agentDir) ??
|
|
23512
|
-
resolveMainModel(modelDeps.getConfiguredModel() ?? undefined),
|
|
23513
|
-
)
|
|
23514
|
-
sessionModelSource.setOverride(token)
|
|
23515
|
-
} catch (e) {
|
|
23516
|
-
process.stderr.write(`telegram gateway: sr-to-claude session-model write failed: ${(e as Error)?.message ?? String(e)}\n`)
|
|
23517
|
-
}
|
|
23518
|
-
} else if (agentDir) {
|
|
23519
|
-
// Default-row tap while on sr-*: the restart must land on the configured
|
|
23520
|
-
// default — a stale sticky override would resurrect the old model.
|
|
23521
|
-
clearSessionModelFile(agentDir)
|
|
23522
|
-
}
|
|
23523
|
-
// Write the restart marker so the post-restart boot card edits into this chat.
|
|
23524
|
-
writeRestartMarker({ chat_id: cbChatId, thread_id: cbThreadId ?? null, ack_message_id: null, ts: Date.now() })
|
|
23525
|
-
stampUserRestartReason('user: sr-to-claude model switch (menu)')
|
|
23526
|
-
if (turnInFlightForGate()) {
|
|
23527
|
-
// Defer restart until the in-flight turn completes (same gate as /restart).
|
|
23528
|
-
pendingRestarts.set(agentName, Date.now())
|
|
23529
|
-
} else {
|
|
23530
|
-
void sweepBeforeSelfRestart().finally(() =>
|
|
23531
|
-
triggerSelfRestart(agentName, 'sr-to-claude-model-switch', 1500),
|
|
23532
|
-
)
|
|
23533
|
-
}
|
|
23534
|
-
return {
|
|
23535
|
-
restartNotice: `🔄 Switching from **${escapeHtmlForTg(prevSessionModel!)}** back to Claude — restarting session cleanly. Claude will be ready in ~30s.`,
|
|
23536
|
-
}
|
|
23537
|
-
}
|
|
23538
|
-
return {}
|
|
23539
|
-
}
|
|
23627
|
+
// Rev 5 (deterministic switch): `recordTypedModelSwitch` and
|
|
23628
|
+
// `recordModelMenuSideEffects` are RETIRED. Every switch now routes through
|
|
23629
|
+
// `scheduleModelRelaunch` / `scheduleModelDefaultRelaunch` inside the handlers,
|
|
23630
|
+
// which own the carrier + in-memory override writes and the premium-recovery
|
|
23631
|
+
// clear. The sr-*→Claude special case is gone: because EVERY switch relaunches,
|
|
23632
|
+
// the sr-* LiteLLM routing is always torn down cleanly by the boot, so there is
|
|
23633
|
+
// no distinct transition to detect. The ACTUAL running model is reconciled at
|
|
23634
|
+
// boot from `.active-session-model` (never from a scraped `selectedModel`).
|
|
23540
23635
|
|
|
23541
23636
|
// ─── Mid-turn ack-queue-apply-confirm for /model + /effort (#3017) ──────────
|
|
23542
23637
|
//
|
|
@@ -23595,24 +23690,17 @@ function enqueueSessionCommand(cmd: PendingSessionCommand): void {
|
|
|
23595
23690
|
async function applyQueuedModelCommand(cmd: PendingSessionCommand): Promise<string> {
|
|
23596
23691
|
const deps = buildModelDeps({ chatId: cmd.chatId, threadId: cmd.threadId })
|
|
23597
23692
|
if (cmd.origin === 'menu') {
|
|
23598
|
-
// Menu SELECT (mdl:s:<
|
|
23599
|
-
//
|
|
23600
|
-
|
|
23693
|
+
// Menu SELECT (mdl:s:<token>) — replay the callback handler, which now
|
|
23694
|
+
// relaunches through the carrier itself (rev 5: no side-effects helper, no
|
|
23695
|
+
// scrape). The relaunch writes its own restart marker so the post-boot card
|
|
23696
|
+
// lands in this chat.
|
|
23601
23697
|
const outcome = await handleModelMenuCallback(cmd.arg, deps)
|
|
23602
|
-
|
|
23603
|
-
outcome,
|
|
23604
|
-
deps,
|
|
23605
|
-
cmd.chatId,
|
|
23606
|
-
cmd.threadId,
|
|
23607
|
-
prevSessionModel,
|
|
23608
|
-
)
|
|
23609
|
-
return restartNotice ?? outcome.reply.text
|
|
23698
|
+
return outcome.reply.text
|
|
23610
23699
|
}
|
|
23611
23700
|
// Typed (and alias/sr menu taps converted to typed at enqueue): run the real
|
|
23612
|
-
// handler
|
|
23701
|
+
// handler, which relaunches through the carrier and owns all side effects.
|
|
23613
23702
|
const reply = await handleModelCommand({ kind: 'set', model: cmd.arg }, deps)
|
|
23614
|
-
|
|
23615
|
-
return reply.text + warning
|
|
23703
|
+
return reply.text
|
|
23616
23704
|
}
|
|
23617
23705
|
|
|
23618
23706
|
/** Apply a queued typed/menu effort command at idle; return the reply body. */
|
|
@@ -23831,17 +23919,12 @@ bot.command('model', async ctx => {
|
|
|
23831
23919
|
})
|
|
23832
23920
|
return
|
|
23833
23921
|
}
|
|
23922
|
+
// Rev 5: the handler relaunches through the carrier and owns every side effect
|
|
23923
|
+
// (override write, carrier, premium-recovery clear). There is no post-hoc
|
|
23924
|
+
// recording — /status is reconciled at boot from `.active-session-model`, so
|
|
23925
|
+
// an unapplied switch can never be optimistically recorded here.
|
|
23834
23926
|
const reply = await handleModelCommand(parsed, deps)
|
|
23835
|
-
|
|
23836
|
-
// actually running (shared with the deferred/menu paths). The sr-*/relaunch
|
|
23837
|
-
// paths already set the override inside scheduleModelRelaunch; an unverified
|
|
23838
|
-
// switch carries no selectedModel so /status is never lied to.
|
|
23839
|
-
const persistWarning = recordTypedModelSwitch(
|
|
23840
|
-
reply,
|
|
23841
|
-
parsed.kind === 'set' ? parsed.model : null,
|
|
23842
|
-
deps,
|
|
23843
|
-
)
|
|
23844
|
-
await switchroomReply(ctx, reply.text + persistWarning, { html: reply.html })
|
|
23927
|
+
await switchroomReply(ctx, reply.text, { html: reply.html })
|
|
23845
23928
|
})
|
|
23846
23929
|
|
|
23847
23930
|
// `/effort` — show or switch the reasoning effort for the live session.
|
|
@@ -27333,51 +27416,15 @@ bot.on('callback_query:data', async ctx => {
|
|
|
27333
27416
|
// rather than the switch-oriented "Switching…".
|
|
27334
27417
|
const isPageNav = data === MODEL_CALLBACK_PAGE_EXTERNAL || data === MODEL_CALLBACK_PAGE_MAIN
|
|
27335
27418
|
await ctx.answerCallbackQuery({ text: isPageNav ? 'Loading…' : 'Switching…' }).catch(() => {})
|
|
27336
|
-
//
|
|
27337
|
-
//
|
|
27338
|
-
//
|
|
27339
|
-
//
|
|
27340
|
-
//
|
|
27341
|
-
//
|
|
27342
|
-
//
|
|
27343
|
-
//
|
|
27344
|
-
// edit would leave the menu stuck button-less.
|
|
27345
|
-
// sr-* TARGET tap: switch TO a non-Claude (LiteLLM/OpenRouter) model.
|
|
27346
|
-
// Parity with the text `/model sr-*` path — claude's native picker rejects
|
|
27347
|
-
// unknown sr-* ids, so an in-place inject can't set them. Carry the token
|
|
27348
|
-
// across a graceful restart (the consume-once `.session-model` carrier) and
|
|
27349
|
-
// relaunch `claude --model sr-*`. Session-only; reverts to the configured
|
|
27350
|
-
// default on the next restart. The sr-* → Claude direction is handled below
|
|
27351
|
-
// via the SELECT/alias outcome + isSrToClaudeTransition.
|
|
27352
|
-
if (data.startsWith(MODEL_CALLBACK_SR)) {
|
|
27353
|
-
const srName = data.slice(MODEL_CALLBACK_SR.length)
|
|
27354
|
-
const srLabel = escapeHtmlForTg(srFriendlyLabel(srName))
|
|
27355
|
-
if (!isValidModelArg(srName)) {
|
|
27356
|
-
await ctx
|
|
27357
|
-
.editMessageText(richMessage('❌ Invalid model name'), { reply_markup: { inline_keyboard: [] } })
|
|
27358
|
-
.catch(() => {})
|
|
27359
|
-
return
|
|
27360
|
-
}
|
|
27361
|
-
await ctx
|
|
27362
|
-
.editMessageText(
|
|
27363
|
-
richMessage(`🔄 Switching session to **${srLabel}** — restarting (~30s). _Session-only; reverts to the configured default on the next restart._`),
|
|
27364
|
-
{ reply_markup: { inline_keyboard: [] } },
|
|
27365
|
-
)
|
|
27366
|
-
.catch(() => {})
|
|
27367
|
-
try {
|
|
27368
|
-
await modelDeps.scheduleModelRelaunch(srName, `user: /model ${srName} (session-only relaunch, menu)`)
|
|
27369
|
-
} catch (err) {
|
|
27370
|
-
await ctx
|
|
27371
|
-
.editMessageText(
|
|
27372
|
-
richMessage(`❌ Could not switch to **${srLabel}**: ${escapeHtmlForTg((err as Error)?.message ?? String(err))}`),
|
|
27373
|
-
{ reply_markup: { inline_keyboard: [] } },
|
|
27374
|
-
)
|
|
27375
|
-
.catch(() => {})
|
|
27376
|
-
}
|
|
27377
|
-
return
|
|
27378
|
-
}
|
|
27419
|
+
// Rev 5 (deterministic switch): EVERY switch tap — Fable/alias (`mdl:alias:`),
|
|
27420
|
+
// sr-* target (`mdl:sr:`), and picker SELECT (`mdl:s:<token>`) — goes through
|
|
27421
|
+
// the single handler, which relaunches through the consume-once carrier
|
|
27422
|
+
// (`scheduleModelRelaunch` / `scheduleModelDefaultRelaunch`). No inject, no
|
|
27423
|
+
// cursor-nav, no scrape, and no post-hoc `recordModelMenuSideEffects`: the
|
|
27424
|
+
// relaunch owns the override + carrier writes, and `.active-session-model`
|
|
27425
|
+
// reconciles /status at boot. The handler writes its own restart marker so
|
|
27426
|
+
// the post-boot card lands in this chat.
|
|
27379
27427
|
try {
|
|
27380
|
-
const prevSessionModel = sessionModelSource.getOverride()
|
|
27381
27428
|
const outcome = await handleModelMenuCallback(data, modelDeps)
|
|
27382
27429
|
// toastOnly: leave the menu untouched — a mid-turn refusal keeps its
|
|
27383
27430
|
// buttons so the operator can tap again. (In the enqueue world this
|
|
@@ -27385,23 +27432,6 @@ bot.on('callback_query:data', async ctx => {
|
|
|
27385
27432
|
// before ever calling the handler — but retained for callers that skip
|
|
27386
27433
|
// the dispatcher gate.)
|
|
27387
27434
|
if (outcome.toastOnly) return
|
|
27388
|
-
// Record the switch (persist/clear sticky override) + drive an sr-*→Claude
|
|
27389
|
-
// graceful restart when needed. Shared with the deferred (queued) apply so
|
|
27390
|
-
// both surfaces record identically. Returns a restart notice when a
|
|
27391
|
-
// session restart was scheduled.
|
|
27392
|
-
const { restartNotice } = recordModelMenuSideEffects(
|
|
27393
|
-
outcome,
|
|
27394
|
-
modelDeps,
|
|
27395
|
-
cbChatId,
|
|
27396
|
-
cbThreadId,
|
|
27397
|
-
prevSessionModel,
|
|
27398
|
-
)
|
|
27399
|
-
if (restartNotice) {
|
|
27400
|
-
await ctx
|
|
27401
|
-
.editMessageText(richMessage(restartNotice), { reply_markup: { inline_keyboard: [] } })
|
|
27402
|
-
.catch(() => {})
|
|
27403
|
-
return
|
|
27404
|
-
}
|
|
27405
27435
|
await ctx
|
|
27406
27436
|
.editMessageText(richMessage(outcome.reply.text), {
|
|
27407
27437
|
reply_markup: modelMenuReplyMarkup(outcome.reply) ?? { inline_keyboard: [] },
|
|
@@ -30432,6 +30462,18 @@ void (async () => {
|
|
|
30432
30462
|
void sweepableIds
|
|
30433
30463
|
} catch {}
|
|
30434
30464
|
|
|
30465
|
+
// Rev 5: capture the restart-marker chat BEFORE the boot-card block
|
|
30466
|
+
// clears it, so the session-model re-hydration block below can send the
|
|
30467
|
+
// switch-confirmation ("✅ Now running X") to the chat that initiated the
|
|
30468
|
+
// switch. A non-`/model` restart leaves these unused.
|
|
30469
|
+
let modelSwitchMarkerChat: { chatId: string; threadId: number | null } | null = null
|
|
30470
|
+
// The DETERMINISTIC "this boot was a /model switch" signal: the reason
|
|
30471
|
+
// stampUserRestartReason() wrote to the clean-shutdown marker. Precise
|
|
30472
|
+
// (distinguishes a model-switch relaunch from any other restart) and works
|
|
30473
|
+
// even when launched === configured (a `/model default` / switch-to-default
|
|
30474
|
+
// apply-boot) — that is how N4 (confirm the default case too) is closed.
|
|
30475
|
+
let modelSwitchReason: string | null = null
|
|
30476
|
+
|
|
30435
30477
|
// Boot card — always post on every gateway start with the restart reason.
|
|
30436
30478
|
// Gated on session marker so a grammY poll-restart (same process, no
|
|
30437
30479
|
// actual restart) does NOT re-post. See session-marker.ts for the
|
|
@@ -30493,6 +30535,18 @@ void (async () => {
|
|
|
30493
30535
|
const ageMs = nowMs - marker.ts
|
|
30494
30536
|
const ageSec = Math.max(1, Math.round(ageMs / 1000))
|
|
30495
30537
|
process.stderr.write(`telegram gateway: boot: restart-marker present, chat_id=${marker.chat_id} age=${ageSec}s within5min=${ageMs < 5 * 60_000}\n`)
|
|
30538
|
+
// Stash the chat for the model-switch confirmation (rev 5) before the
|
|
30539
|
+
// marker is cleared. Bounded to a recent marker (<5min) so a stale
|
|
30540
|
+
// marker can't misdirect a confirmation. Pair it with the /model
|
|
30541
|
+
// switch reason (from the clean-shutdown marker) so the confirmation
|
|
30542
|
+
// fires ONLY on a genuine /model relaunch (not a plain /restart that
|
|
30543
|
+
// also wrote a marker chat).
|
|
30544
|
+
if (ageMs < 5 * 60_000) {
|
|
30545
|
+
modelSwitchMarkerChat = { chatId: marker.chat_id, threadId: marker.thread_id }
|
|
30546
|
+
if (typeof cleanMarker?.reason === 'string' && cleanMarker.reason.startsWith('user: /model')) {
|
|
30547
|
+
modelSwitchReason = cleanMarker.reason
|
|
30548
|
+
}
|
|
30549
|
+
}
|
|
30496
30550
|
clearRestartMarker()
|
|
30497
30551
|
}
|
|
30498
30552
|
|
|
@@ -30529,7 +30583,21 @@ void (async () => {
|
|
|
30529
30583
|
})
|
|
30530
30584
|
}
|
|
30531
30585
|
|
|
30532
|
-
|
|
30586
|
+
// N3 (dedup to ONE card per switch): a `/model` apply-boot already
|
|
30587
|
+
// sends the `✅ Now running X` confirmation from the re-hydration block
|
|
30588
|
+
// below (into the same chat). Suppress the generic restart boot card
|
|
30589
|
+
// here so a deliberate switch yields exactly one card — the
|
|
30590
|
+
// confirmation, which carries the operative "here's your model" info.
|
|
30591
|
+
// Only when we KNOW it was a /model switch (reason) AND we will send the
|
|
30592
|
+
// confirmation (marker chat captured); otherwise the boot card fires
|
|
30593
|
+
// normally. Version/quota remain available via /status.
|
|
30594
|
+
const suppressBootCardForModelSwitch =
|
|
30595
|
+
modelSwitchReason != null && modelSwitchMarkerChat != null
|
|
30596
|
+
if (target && suppressBootCardForModelSwitch) {
|
|
30597
|
+
process.stderr.write(
|
|
30598
|
+
`telegram gateway: boot: suppressing generic boot card — /model switch apply-boot (reason=${JSON.stringify(modelSwitchReason)}); the confirmation card replaces it\n`,
|
|
30599
|
+
)
|
|
30600
|
+
} else if (target) {
|
|
30533
30601
|
const { chatId, threadId, ackMsgId } = target
|
|
30534
30602
|
// First-write-wins dedupe: if bridge-reconnect already
|
|
30535
30603
|
// claimed (its IPC client connected before this IIFE
|
|
@@ -30635,9 +30703,55 @@ void (async () => {
|
|
|
30635
30703
|
// a phantom session override.
|
|
30636
30704
|
return resolveMainModel(raw ?? undefined)
|
|
30637
30705
|
})()
|
|
30638
|
-
|
|
30639
|
-
|
|
30706
|
+
// `launched !== configured` is the DETERMINISTIC "a model switch
|
|
30707
|
+
// landed" signal (F1): the carrier is consume-once, so a launched
|
|
30708
|
+
// model that differs from the configured default only ever
|
|
30709
|
+
// happens on a genuine apply-boot. Seed the in-memory override
|
|
30710
|
+
// from it — this is the ONLY success reporting, sourced from the
|
|
30711
|
+
// real post-boot signal (`.active-session-model`), never from a
|
|
30712
|
+
// scraped pane or an optimistic record.
|
|
30713
|
+
const isApplyBoot = launched.length > 0 && launched !== configured
|
|
30714
|
+
sessionModelSource.setOverride(isApplyBoot ? launched : null)
|
|
30715
|
+
// Diagnosability (rev 5): the applied model is now always
|
|
30716
|
+
// greppable — `grep 'gw /model relaunch applied'`. F4 note:
|
|
30717
|
+
// `launched` is the REQUESTED token start.sh wrote before `exec
|
|
30718
|
+
// claude` (it is NOT a post-launch confirmation). If a shape-valid
|
|
30719
|
+
// but unknown Claude id was requested, `--fallback-model` may mask
|
|
30720
|
+
// it: claude serves a fallback while this records the requested
|
|
30721
|
+
// token. That divergence is NOT a persistent lie — the transcript's
|
|
30722
|
+
// `message.model` (noteTranscriptModel) reclaims the source from
|
|
30723
|
+
// this override on the first assistant line, correcting /status to
|
|
30724
|
+
// the model actually serving calls. The pre-first-assistant window
|
|
30725
|
+
// is the only optimistic window (G2), and it is bounded and
|
|
30726
|
+
// self-healing; it is documented, not silently asserted as success.
|
|
30727
|
+
process.stderr.write(
|
|
30728
|
+
`telegram gateway: gw /model relaunch applied agent=${getMyAgentName()} launched=${launched || '(none)'} configured=${configured} override=${isApplyBoot ? 'set' : 'cleared'}\n`,
|
|
30640
30729
|
)
|
|
30730
|
+
// Switch-confirmation (F1 / PLAN §4 step 2): on a /model apply-boot
|
|
30731
|
+
// with a known initiating chat, send ONE confirmation built from the
|
|
30732
|
+
// ACTUAL launched model — never optimistic. Keyed on the DETERMINISTIC
|
|
30733
|
+
// /model switch reason (from the clean-shutdown marker), not on
|
|
30734
|
+
// `launched !== configured`, so it ALSO fires when a switch landed on
|
|
30735
|
+
// the configured default (`/model default`, or `/model <configured>`)
|
|
30736
|
+
// — N4. The generic boot card is suppressed for this boot (N3), so
|
|
30737
|
+
// this is the single card the operator sees for the switch.
|
|
30738
|
+
if (modelSwitchReason != null && modelSwitchMarkerChat) {
|
|
30739
|
+
const chat = modelSwitchMarkerChat
|
|
30740
|
+
const body = isApplyBoot
|
|
30741
|
+
? `✅ Now running \`${launched}\` — session-only, reverts to the configured model on the next restart. Fresh session; memory and the handoff briefing carry the context.`
|
|
30742
|
+
: `✅ Now running \`${launched || configured}\` (the configured default) — fresh session; memory and the handoff briefing carry the context.`
|
|
30743
|
+
// allow-raw-bot-api: one-shot boot confirmation, same shape as the session-model alert relay below
|
|
30744
|
+
void lockedBot.api
|
|
30745
|
+
.sendMessage(chat.chatId, body, {
|
|
30746
|
+
parse_mode: 'Markdown',
|
|
30747
|
+
...(chat.threadId != null ? { message_thread_id: chat.threadId } : {}),
|
|
30748
|
+
})
|
|
30749
|
+
.catch((err: unknown) =>
|
|
30750
|
+
process.stderr.write(
|
|
30751
|
+
`telegram gateway: model-switch confirmation send failed: ${(err as Error)?.message ?? String(err)}\n`,
|
|
30752
|
+
),
|
|
30753
|
+
)
|
|
30754
|
+
}
|
|
30641
30755
|
} catch { /* leave override as-is on a bad read */ }
|
|
30642
30756
|
}
|
|
30643
30757
|
|