switchroom 0.17.10 → 0.18.3
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/workspace-dynamic-hook.sh +12 -13
- package/dist/agent-scheduler/index.js +27 -1
- package/dist/auth-broker/index.js +6161 -151
- package/dist/cli/notion-write-pretool.mjs +29 -2
- package/dist/cli/switchroom.js +578 -454
- package/dist/host-control/main.js +6182 -172
- package/dist/vault/approvals/kernel-server.js +5891 -164
- package/dist/vault/broker/server.js +6597 -881
- package/package.json +1 -1
- package/profiles/_base/settings.json.hbs +2 -2
- package/profiles/_base/start.sh.hbs +170 -21
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md +2 -2
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/auth-snapshot-format.ts +22 -24
- package/telegram-plugin/context-exhaustion.ts +124 -0
- package/telegram-plugin/dist/gateway/gateway.js +24086 -8727
- package/telegram-plugin/gateway/activity-card-store.ts +76 -0
- package/telegram-plugin/gateway/gateway.ts +480 -85
- package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
- package/telegram-plugin/gateway/model-command.ts +70 -10
- package/telegram-plugin/package.json +6 -0
- package/telegram-plugin/quota-watch.ts +4 -6
- package/telegram-plugin/registry/turns-schema.test.ts +97 -0
- package/telegram-plugin/registry/turns-schema.ts +78 -0
- package/telegram-plugin/render/ir.ts +209 -0
- package/telegram-plugin/render/parse.ts +363 -0
- package/telegram-plugin/render/render.ts +440 -0
- package/telegram-plugin/render/rich-render.ts +72 -0
- package/telegram-plugin/stream-controller.ts +14 -3
- package/telegram-plugin/tests/activity-card-store.test.ts +94 -0
- package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +11 -0
- package/telegram-plugin/tests/feed-survival.test.ts +39 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
- package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
- package/telegram-plugin/tests/model-command.test.ts +193 -16
- package/telegram-plugin/tests/narrative-render.test.ts +125 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
- package/telegram-plugin/tests/quota-watch.test.ts +1 -4
- package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
- package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
- package/telegram-plugin/tests/render/parse.test.ts +393 -0
- package/telegram-plugin/tests/render/render.test.ts +436 -0
- package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +98 -3
- package/telegram-plugin/turn-liveness-floor.ts +35 -1
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
- package/telegram-plugin/worktree-watch-cwds.ts +92 -17
- package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +9 -2
- package/vendor/hindsight-memory/scripts/recall.py +64 -6
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
- package/vendor/hindsight-memory/tests/test_client.py +43 -0
- package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
|
@@ -294,6 +294,7 @@ import {
|
|
|
294
294
|
shouldArmOrphanedReplyTimeout,
|
|
295
295
|
ORPHANED_REPLY_TIMEOUT_MS,
|
|
296
296
|
ORPHANED_REPLY_MAX_REARMS,
|
|
297
|
+
LivenessTracker,
|
|
297
298
|
} from '../context-exhaustion.js'
|
|
298
299
|
import {
|
|
299
300
|
decideTurnFlush,
|
|
@@ -346,6 +347,7 @@ import {
|
|
|
346
347
|
buildModelMenu,
|
|
347
348
|
handleModelMenuCallback,
|
|
348
349
|
isSrToClaudeTransition,
|
|
350
|
+
isValidModelArg,
|
|
349
351
|
MODEL_CALLBACK_PREFIX,
|
|
350
352
|
MODEL_CALLBACK_HEADER,
|
|
351
353
|
MODEL_CALLBACK_SR,
|
|
@@ -357,6 +359,7 @@ import {
|
|
|
357
359
|
type ModelMenuReply,
|
|
358
360
|
} from './model-command.js'
|
|
359
361
|
import { discoverModels, selectModel } from '../../src/agents/model-picker.js'
|
|
362
|
+
import { resolveMainModel } from '../../src/agents/scaffold.js'
|
|
360
363
|
import {
|
|
361
364
|
parseEffortCommand,
|
|
362
365
|
handleEffortCommand,
|
|
@@ -419,6 +422,7 @@ import {
|
|
|
419
422
|
writeActivityCardRecord,
|
|
420
423
|
clearActivityCardRecord,
|
|
421
424
|
runActivityCardBootReaper,
|
|
425
|
+
runActivityCardMidSessionReaper,
|
|
422
426
|
restartOrphanCardFinalizeText,
|
|
423
427
|
type ActivityCardStoreFsSeam,
|
|
424
428
|
} from './activity-card-store.js'
|
|
@@ -427,7 +431,7 @@ import { shouldSuppressRepresent } from './represent-guard.js'
|
|
|
427
431
|
import { shouldDeferEscalationForBridge } from './escalation-bridge-gate.js'
|
|
428
432
|
import { createInboundSpool } from './inbound-spool.js'
|
|
429
433
|
import { purgeStaleTurnsForChat } from './turn-state-purge.js'
|
|
430
|
-
import { decideInboundDelivery } from './inbound-delivery-gate.js'
|
|
434
|
+
import { decideInboundDelivery, reserveInboundDelivery } from './inbound-delivery-gate.js'
|
|
431
435
|
import { mayDrainBufferedInbound, shouldArmNoReplyDrain } from './serialize-drain-gate.js'
|
|
432
436
|
import { decideFeedReopen } from './feed-reopen-gate.js'
|
|
433
437
|
import {
|
|
@@ -662,6 +666,7 @@ import {
|
|
|
662
666
|
findRecentTurnsForChat,
|
|
663
667
|
getTurnByKey,
|
|
664
668
|
markTurnResumed,
|
|
669
|
+
reapStaleOpenTurns,
|
|
665
670
|
} from '../registry/turns-schema.js'
|
|
666
671
|
import {
|
|
667
672
|
buildResumeInterruptedInbound,
|
|
@@ -1949,6 +1954,25 @@ const obligationEscalateInFlight = new Set<string>()
|
|
|
1949
1954
|
// drain on the bare turn-end signal.
|
|
1950
1955
|
const SERIALIZE_UNTIL_REPLIED_ENABLED =
|
|
1951
1956
|
process.env.SWITCHROOM_SERIALIZE_UNTIL_REPLIED !== '0'
|
|
1957
|
+
// #2917 — rapid-fire per-chat FIFO. The #1556 delivery gate documents its
|
|
1958
|
+
// `turnInFlight` input as a LIVE read ("evaluated at delivery time — not a
|
|
1959
|
+
// receipt-time snapshot"), but handleInbound passes a receipt-time snapshot
|
|
1960
|
+
// (`turnInFlightAtReceipt`) taken at function entry. Under rapid-fire two
|
|
1961
|
+
// same-chat inbounds each snapshot "idle" during the other's async lead-in
|
|
1962
|
+
// (attachment download, composer-clear), both pass the gate, and both are
|
|
1963
|
+
// delivered — so replies come back reordered (observed 1,2,4,5,3,7,8,6). The
|
|
1964
|
+
// fix reads the gate LIVE off `claudeBusyKeys` (which does NOT yet contain
|
|
1965
|
+
// THIS inbound's own key — that is only marked at delivery — so it can't
|
|
1966
|
+
// self-block) AND reserves the key synchronously BEFORE the composer-clear
|
|
1967
|
+
// await, so a concurrent same-chat inbound observes the reservation and
|
|
1968
|
+
// buffers behind it, preserving FIFO. Nothing is dropped: a buffered inbound
|
|
1969
|
+
// drains on turn-complete / idle exactly as before. Kill switch (=0) restores
|
|
1970
|
+
// the receipt-snapshot behaviour. Only the non-cutover path is affected — the
|
|
1971
|
+
// delivery-machine cutover keeps its own at-receipt machine snapshot (reading
|
|
1972
|
+
// the machine live WOULD self-block, since its inbound event already advanced
|
|
1973
|
+
// it for this key).
|
|
1974
|
+
const SERIALIZE_INBOUND_DELIVERY_ENABLED =
|
|
1975
|
+
process.env.SWITCHROOM_SERIALIZE_INBOUND_DELIVERY !== '0'
|
|
1952
1976
|
// Component 2 (bounded no-reply escape hatch). A turn that legitimately
|
|
1953
1977
|
// ends with NO reply (handback ack, NO_REPLY marker, silent-end) sets
|
|
1954
1978
|
// finalAnswerDelivered=false and would block the serialize gate forever.
|
|
@@ -2348,13 +2372,15 @@ type CurrentTurn = {
|
|
|
2348
2372
|
silentAnchorText: string
|
|
2349
2373
|
capturedText: string[]
|
|
2350
2374
|
orphanedReplyTimeoutId: ReturnType<typeof setTimeout> | null
|
|
2351
|
-
//
|
|
2352
|
-
//
|
|
2353
|
-
//
|
|
2354
|
-
//
|
|
2355
|
-
//
|
|
2356
|
-
//
|
|
2357
|
-
|
|
2375
|
+
// Per-turn liveness tracker for the orphaned-reply backstop. Owns
|
|
2376
|
+
// `lastStreamEventAt` (stamped on ANY genuine stream event so a model
|
|
2377
|
+
// reasoning pause keeps the turn "recently streaming" and re-arms the fuse
|
|
2378
|
+
// instead of firing) and the rearm counter (bounded by
|
|
2379
|
+
// ORPHANED_REPLY_MAX_REARMS so a genuinely wedged single tool that never
|
|
2380
|
+
// streams still surfaces after the cap). The counter is zeroed on every
|
|
2381
|
+
// genuine stream event, so the cap only bites CONSECUTIVE silent expiries.
|
|
2382
|
+
// Fresh instance per enqueue (below). See context-exhaustion.ts.
|
|
2383
|
+
liveness: LivenessTracker
|
|
2358
2384
|
// Component 3 (turn-origin reply routing). A stable per-turn identity,
|
|
2359
2385
|
// `${registryKey-or-chatKey}#${startedAt}`, assigned when the turn
|
|
2360
2386
|
// starts and stamped into the inbound meta (`origin_turn_id`) so a reply
|
|
@@ -6230,6 +6256,127 @@ async function activityCardBootReaper(): Promise<void> {
|
|
|
6230
6256
|
)
|
|
6231
6257
|
}
|
|
6232
6258
|
}
|
|
6259
|
+
// ─── Mid-session stale-card reaper (#2918) ──────────────────────────────────
|
|
6260
|
+
// The boot reapers (markOrphanedWithTimeoutClassification + activityCardBoot-
|
|
6261
|
+
// Reaper) run ONCE at startup. A turn whose owning SDK subprocess dies
|
|
6262
|
+
// mid-session (SIGKILL / OOM / crash) without a clean end leaves its turns-DB
|
|
6263
|
+
// row `ended_at IS NULL` and its activity card frozen on "working…" until the
|
|
6264
|
+
// NEXT gateway boot — often many hours. This periodic sweep, running inside the
|
|
6265
|
+
// live gateway, finalizes those orphans without waiting for a restart.
|
|
6266
|
+
//
|
|
6267
|
+
// LIVENESS (the critical correctness guard): a turn/card is reaped ONLY when no
|
|
6268
|
+
// live in-flight turn owns it. The live set is derived from `currentTurnMap`
|
|
6269
|
+
// (per-topic byKey) PLUS the singleton `currentTurn` mirror, so it is correct
|
|
6270
|
+
// whether or not the per-topic-isolation flag is on. A healthy long-running
|
|
6271
|
+
// turn is always in that set and is never swept. A TTL gate is a secondary
|
|
6272
|
+
// guard against the just-started-not-yet-tracked race.
|
|
6273
|
+
//
|
|
6274
|
+
// Kill switch: set SWITCHROOM_MID_SESSION_CARD_REAPER=0 to disable (clean
|
|
6275
|
+
// revert, mirrors #2919). TTL/interval overridable via env for tuning.
|
|
6276
|
+
const MID_SESSION_CARD_REAPER_ENABLED =
|
|
6277
|
+
process.env.SWITCHROOM_MID_SESSION_CARD_REAPER !== '0'
|
|
6278
|
+
const MID_SESSION_CARD_REAPER_TTL_MS = (() => {
|
|
6279
|
+
const v = Number(process.env.SWITCHROOM_MID_SESSION_CARD_REAPER_TTL_MS)
|
|
6280
|
+
return Number.isFinite(v) && v > 0 ? v : 15 * 60_000 // 15 min
|
|
6281
|
+
})()
|
|
6282
|
+
const MID_SESSION_CARD_REAPER_INTERVAL_MS = (() => {
|
|
6283
|
+
const v = Number(process.env.SWITCHROOM_MID_SESSION_CARD_REAPER_INTERVAL_MS)
|
|
6284
|
+
return Number.isFinite(v) && v > 0 ? v : 5 * 60_000 // 5 min
|
|
6285
|
+
})()
|
|
6286
|
+
|
|
6287
|
+
// Snapshot the turn_keys / topic keys owned by a live in-flight turn right now.
|
|
6288
|
+
function liveTurnKeySets(): { registryKeys: Set<string>; topicKeys: Set<string> } {
|
|
6289
|
+
const registryKeys = new Set<string>()
|
|
6290
|
+
const topicKeys = new Set<string>()
|
|
6291
|
+
const add = (t: CurrentTurn | null | undefined): void => {
|
|
6292
|
+
if (t == null) return
|
|
6293
|
+
if (t.registryKey != null && t.registryKey.length > 0) registryKeys.add(t.registryKey)
|
|
6294
|
+
topicKeys.add(statusKey(t.sessionChatId, t.sessionThreadId))
|
|
6295
|
+
}
|
|
6296
|
+
for (const t of currentTurnMap.byKey.values()) add(t)
|
|
6297
|
+
add(currentTurn) // flag-OFF store + most-recent mirror
|
|
6298
|
+
return { registryKeys, topicKeys }
|
|
6299
|
+
}
|
|
6300
|
+
|
|
6301
|
+
async function runMidSessionCardReaper(): Promise<void> {
|
|
6302
|
+
if (!MID_SESSION_CARD_REAPER_ENABLED) return
|
|
6303
|
+
const now = Date.now()
|
|
6304
|
+
const { registryKeys, topicKeys } = liveTurnKeySets()
|
|
6305
|
+
|
|
6306
|
+
// 1) Stamp ownerless open turns-DB rows (the durable spinner signal).
|
|
6307
|
+
if (turnsDb != null) {
|
|
6308
|
+
try {
|
|
6309
|
+
const { reaped, reapedTurnKeys } = reapStaleOpenTurns(turnsDb, {
|
|
6310
|
+
activeTurnKeys: registryKeys,
|
|
6311
|
+
ttlMs: MID_SESSION_CARD_REAPER_TTL_MS,
|
|
6312
|
+
now,
|
|
6313
|
+
})
|
|
6314
|
+
if (reaped > 0) {
|
|
6315
|
+
process.stderr.write(
|
|
6316
|
+
`telegram gateway: mid-session reaper stamped ${reaped} orphaned turn(s) ` +
|
|
6317
|
+
`as 'restart' (${reapedTurnKeys.join(',')})\n`,
|
|
6318
|
+
)
|
|
6319
|
+
}
|
|
6320
|
+
} catch (err) {
|
|
6321
|
+
process.stderr.write(
|
|
6322
|
+
`telegram gateway: mid-session turn reaper error: ${(err as Error).message}\n`,
|
|
6323
|
+
)
|
|
6324
|
+
}
|
|
6325
|
+
}
|
|
6326
|
+
|
|
6327
|
+
// 2) Finalize the leftover visible activity card(s) for those dead turns.
|
|
6328
|
+
if (activityCardPersistEnabled) {
|
|
6329
|
+
try {
|
|
6330
|
+
const { finalized, vanished, total } = await runActivityCardMidSessionReaper({
|
|
6331
|
+
path: ACTIVITY_CARD_STORE_PATH,
|
|
6332
|
+
fs: activityCardStoreFs,
|
|
6333
|
+
isLive: (record) => topicKeys.has(record.turnKey),
|
|
6334
|
+
ttlMs: MID_SESSION_CARD_REAPER_TTL_MS,
|
|
6335
|
+
now,
|
|
6336
|
+
finalizeCard: (record) =>
|
|
6337
|
+
robustApiCall(
|
|
6338
|
+
() =>
|
|
6339
|
+
lockedBot.api.editMessageText(
|
|
6340
|
+
record.chatId,
|
|
6341
|
+
record.activityMessageId,
|
|
6342
|
+
richMessage(restartOrphanCardFinalizeText(record.startedAt)),
|
|
6343
|
+
{},
|
|
6344
|
+
),
|
|
6345
|
+
{
|
|
6346
|
+
chat_id: record.chatId,
|
|
6347
|
+
...(record.threadId != null ? { threadId: record.threadId } : {}),
|
|
6348
|
+
verb: 'activity-card.mid-session-reap-finalize',
|
|
6349
|
+
},
|
|
6350
|
+
),
|
|
6351
|
+
unpinCard: (record) =>
|
|
6352
|
+
robustApiCall(
|
|
6353
|
+
() => lockedBot.api.unpinChatMessage(record.chatId, record.activityMessageId),
|
|
6354
|
+
{
|
|
6355
|
+
chat_id: record.chatId,
|
|
6356
|
+
...(record.threadId != null ? { threadId: record.threadId } : {}),
|
|
6357
|
+
verb: 'activity-card.mid-session-reap-unpin',
|
|
6358
|
+
},
|
|
6359
|
+
),
|
|
6360
|
+
})
|
|
6361
|
+
if (total > 0) {
|
|
6362
|
+
process.stderr.write(
|
|
6363
|
+
`telegram gateway: activity-card: mid-session finalized ${finalized}/${total} ` +
|
|
6364
|
+
`(vanished ${vanished}/${total}) orphaned card(s) (at-most-once)\n`,
|
|
6365
|
+
)
|
|
6366
|
+
}
|
|
6367
|
+
} catch (err) {
|
|
6368
|
+
process.stderr.write(
|
|
6369
|
+
`telegram gateway: mid-session card reaper error: ${(err as Error).message}\n`,
|
|
6370
|
+
)
|
|
6371
|
+
}
|
|
6372
|
+
}
|
|
6373
|
+
}
|
|
6374
|
+
|
|
6375
|
+
const midSessionCardReaper = setInterval(() => {
|
|
6376
|
+
void runMidSessionCardReaper()
|
|
6377
|
+
}, MID_SESSION_CARD_REAPER_INTERVAL_MS)
|
|
6378
|
+
midSessionCardReaper.unref()
|
|
6379
|
+
|
|
6233
6380
|
// NOTE: statusPinBootCleanup() is deliberately NOT invoked here at import time.
|
|
6234
6381
|
// The status-pin store is a SHARED per-agent file, and cleanup issues real
|
|
6235
6382
|
// unpinChatMessage calls. On a double-boot the losing gateway must NOT touch
|
|
@@ -6596,6 +6743,11 @@ function parsePositiveMsEnv(name: string, fallbackMs: number): number {
|
|
|
6596
6743
|
const n = Number(raw)
|
|
6597
6744
|
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallbackMs
|
|
6598
6745
|
}
|
|
6746
|
+
// Orphaned-reply "recently streaming" window (thinking-pause fix). If a genuine
|
|
6747
|
+
// stream event landed within this window, the fuse re-arms instead of firing —
|
|
6748
|
+
// so a long model reasoning pause (which emits no text/tool events) is
|
|
6749
|
+
// survivable while a genuine multi-minute hang still surfaces. Default 120 s.
|
|
6750
|
+
const ORPHANED_REPLY_STREAM_WINDOW_MS = parsePositiveMsEnv('SWITCHROOM_ORPHANED_REPLY_STREAM_WINDOW_MS', 120_000)
|
|
6599
6751
|
const SILENCE_FALLBACK_MS = parsePositiveMsEnv('SWITCHROOM_SILENCE_FALLBACK_MS', 300_000)
|
|
6600
6752
|
const SILENCE_FALLBACK_HARD_MS = parsePositiveMsEnv('SWITCHROOM_SILENCE_FALLBACK_HARD_MS', 900_000)
|
|
6601
6753
|
// #2527 — mid-turn liveness floor threshold (default 45s). Still gates the
|
|
@@ -12411,20 +12563,32 @@ function resetOrphanedReplyTimeout(): void {
|
|
|
12411
12563
|
}
|
|
12412
12564
|
return false
|
|
12413
12565
|
})()
|
|
12414
|
-
|
|
12415
|
-
|
|
12416
|
-
|
|
12417
|
-
|
|
12418
|
-
|
|
12419
|
-
|
|
12420
|
-
|
|
12421
|
-
|
|
12422
|
-
|
|
12423
|
-
|
|
12424
|
-
|
|
12425
|
-
|
|
12426
|
-
|
|
12427
|
-
|
|
12566
|
+
// Route the rearm decision through the per-turn LivenessTracker. It
|
|
12567
|
+
// rearms when working OR recently-streaming (thinking-pause survival)
|
|
12568
|
+
// OR human-waiting; working/recently-streaming rearms count against
|
|
12569
|
+
// ORPHANED_REPLY_MAX_REARMS while human-wait rearms stay uncapped.
|
|
12570
|
+
const now = Date.now()
|
|
12571
|
+
const recentlyStreaming = t.liveness.recentlyStreaming(now, ORPHANED_REPLY_STREAM_WINDOW_MS)
|
|
12572
|
+
const decision = t.liveness.decideOnExpiry({
|
|
12573
|
+
working,
|
|
12574
|
+
humanWaiting,
|
|
12575
|
+
now,
|
|
12576
|
+
windowMs: ORPHANED_REPLY_STREAM_WINDOW_MS,
|
|
12577
|
+
maxRearms: ORPHANED_REPLY_MAX_REARMS,
|
|
12578
|
+
})
|
|
12579
|
+
if (decision.rearm) {
|
|
12580
|
+
process.stderr.write(
|
|
12581
|
+
`telegram gateway: orphaned-reply fuse expired — re-arming` +
|
|
12582
|
+
` (rearm ${t.liveness.orphanedReplyRearmCount}/${ORPHANED_REPLY_MAX_REARMS},` +
|
|
12583
|
+
` in_flight=${toolFlightTracker.inFlightCount()},` +
|
|
12584
|
+
` human_wait=${humanWaiting},` +
|
|
12585
|
+
` recently_streaming=${recentlyStreaming},` +
|
|
12586
|
+
` bg_work=${pendingProgress.hasPendingAsyncDispatch(turnKey)})\n`,
|
|
12587
|
+
)
|
|
12588
|
+
resetOrphanedReplyTimeout()
|
|
12589
|
+
return
|
|
12590
|
+
}
|
|
12591
|
+
if (decision.countsAgainstCap) {
|
|
12428
12592
|
process.stderr.write(
|
|
12429
12593
|
`telegram gateway: orphaned-reply rearm cap reached (${ORPHANED_REPLY_MAX_REARMS}) — forcing backstop despite working state\n`,
|
|
12430
12594
|
)
|
|
@@ -12900,11 +13064,19 @@ function feedHeartbeatTick(): void {
|
|
|
12900
13064
|
// durable record once it completes.
|
|
12901
13065
|
// - 'emit' → genuine in-flight post-answer activity; render the card below.
|
|
12902
13066
|
const subagentAt = turn.subagentActivityAt
|
|
13067
|
+
// Fix 3 (sub-agent-delegation freeze): a foreground `Task`/`Agent` still
|
|
13068
|
+
// tracked in `turn.foregroundSubAgents` is POSITIVE evidence the worker
|
|
13069
|
+
// has not reported finished — see the doc comment on
|
|
13070
|
+
// `PostAnswerLivenessInput.stillDispatched` in turn-liveness-floor.ts for
|
|
13071
|
+
// why this must bypass the staleness cap rather than let a single long
|
|
13072
|
+
// silent step freeze the card mid-delegation.
|
|
13073
|
+
const stillDispatched = turn.foregroundSubAgents.size > 0
|
|
12903
13074
|
const livenessVerdict = evaluatePostAnswerLiveness({
|
|
12904
13075
|
subagentActivityAt: subagentAt,
|
|
12905
13076
|
finalAnswerDeliveredAt: turn.finalAnswerDeliveredAt,
|
|
12906
13077
|
now: Date.now(),
|
|
12907
13078
|
staleCapMs: POST_ANSWER_LIVENESS_STALE_MS,
|
|
13079
|
+
stillDispatched,
|
|
12908
13080
|
})
|
|
12909
13081
|
if (livenessVerdict !== 'emit' || subagentAt == null) return // idle gap or stale worker → stay silent (the `== null` also narrows subagentAt for the elapsed below)
|
|
12910
13082
|
// A background worker is genuinely active after the answer. Open or maintain
|
|
@@ -13278,6 +13450,22 @@ function surfaceConsolidationLegibility(
|
|
|
13278
13450
|
}
|
|
13279
13451
|
|
|
13280
13452
|
function handleSessionEvent(ev: SessionEvent): void {
|
|
13453
|
+
// Per-turn liveness stamp (orphaned-reply thinking-pause fix). Stamp
|
|
13454
|
+
// lastStreamEventAt AND reset the rearm counter on ANY genuine stream event,
|
|
13455
|
+
// under ONE shared predicate: a live turn is present and this is not the
|
|
13456
|
+
// synthetic durationMs===-1 turn_end (the fire callback's own re-dispatch).
|
|
13457
|
+
// The counter reset MUST live here at the dispatcher — NOT inside
|
|
13458
|
+
// resetOrphanedReplyTimeout() (which is called from the fire callback one
|
|
13459
|
+
// line after the counter increments) and NOT tied to a single case (e.g.
|
|
13460
|
+
// tool_result does not call resetOrphanedReplyTimeout). onStreamEvent applies
|
|
13461
|
+
// the `!(turn_end && -1)` half of the predicate internally.
|
|
13462
|
+
{
|
|
13463
|
+
const liveTurn = currentTurn
|
|
13464
|
+
if (liveTurn != null) {
|
|
13465
|
+
const durationMs = ev.kind === 'turn_end' ? ev.durationMs : undefined
|
|
13466
|
+
liveTurn.liveness.onStreamEvent(ev.kind, durationMs, Date.now())
|
|
13467
|
+
}
|
|
13468
|
+
}
|
|
13281
13469
|
switch (ev.kind) {
|
|
13282
13470
|
case 'enqueue': {
|
|
13283
13471
|
// Drain any orphaned typing-wrap entries left over from a crashed
|
|
@@ -13314,6 +13502,14 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
13314
13502
|
prior.answerStream.stop()
|
|
13315
13503
|
prior.answerStream = null
|
|
13316
13504
|
}
|
|
13505
|
+
// Bounded-leak hardening (A5): clear the prior turn's orphaned-reply
|
|
13506
|
+
// fuse before it is superseded. The fire callback re-reads currentTurn
|
|
13507
|
+
// and no-ops on a stale turn, but proactively clearing the timer avoids
|
|
13508
|
+
// a bounded pile-up of dangling timers across rapid steer/queue turns.
|
|
13509
|
+
if (prior?.orphanedReplyTimeoutId != null) {
|
|
13510
|
+
clearTimeout(prior.orphanedReplyTimeoutId)
|
|
13511
|
+
prior.orphanedReplyTimeoutId = null
|
|
13512
|
+
}
|
|
13317
13513
|
// #1067: swap the entire turn atom in one assignment. Every
|
|
13318
13514
|
// handler captures `const turn = currentTurn` at entry, so a
|
|
13319
13515
|
// captured-then-awaited read can't reattribute to the new turn.
|
|
@@ -13373,7 +13569,9 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
13373
13569
|
silentAnchorText: '',
|
|
13374
13570
|
capturedText: [],
|
|
13375
13571
|
orphanedReplyTimeoutId: null,
|
|
13376
|
-
|
|
13572
|
+
// Fresh liveness tracker: lastStreamEventAt seeded to the turn start
|
|
13573
|
+
// so a turn that never streams still trips the fuse after windowMs.
|
|
13574
|
+
liveness: new LivenessTracker(startedAt),
|
|
13377
13575
|
turnId,
|
|
13378
13576
|
registryKey: null,
|
|
13379
13577
|
noReplyDrainTimer: null,
|
|
@@ -14043,10 +14241,25 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
14043
14241
|
if (ev.durationMs === -1) {
|
|
14044
14242
|
const turn = currentTurn
|
|
14045
14243
|
const key = turn != null ? statusKey(turn.sessionChatId, turn.sessionThreadId) : ''
|
|
14046
|
-
|
|
14244
|
+
// Widened to also suppress while the turn is RECENTLY STREAMING — a
|
|
14245
|
+
// model reasoning pause emits no tool/text events (so
|
|
14246
|
+
// isLegitimatelyWorking is false) but a genuine stream landed within
|
|
14247
|
+
// the window, so the turn is alive and must not be torn down.
|
|
14248
|
+
// ACCEPTED TRADE-OFF (F3): the context-exhaustion recovery latency via
|
|
14249
|
+
// THIS backstop path grows from ~30 s to ~120-150 s, because the
|
|
14250
|
+
// "Prompt is too long" marker is itself a genuine `text` event that
|
|
14251
|
+
// stamps recentlyStreaming. This is acceptable — the primary
|
|
14252
|
+
// context-exhaustion teardown is the immediate `endCurrentTurnAtomic`
|
|
14253
|
+
// in `case 'text'` (isContextExhaustionText) above; this backstop only
|
|
14254
|
+
// matters if that path is missed, and it still COMPLETES once the
|
|
14255
|
+
// window lapses. Recovery is delayed, never suppressed forever.
|
|
14256
|
+
const recentlyStreaming =
|
|
14257
|
+
turn != null && turn.liveness.recentlyStreaming(Date.now(), ORPHANED_REPLY_STREAM_WINDOW_MS)
|
|
14258
|
+
if (isLegitimatelyWorking(key) || recentlyStreaming) {
|
|
14047
14259
|
process.stderr.write(
|
|
14048
14260
|
`telegram gateway: synthetic turn_end suppressed — legitimately working` +
|
|
14049
14261
|
` (in_flight=${toolFlightTracker.inFlightCount()},` +
|
|
14262
|
+
` recently_streaming=${recentlyStreaming},` +
|
|
14050
14263
|
` bg_work=${turn != null ? pendingProgress.hasPendingAsyncDispatch(key) : false})\n`,
|
|
14051
14264
|
)
|
|
14052
14265
|
return
|
|
@@ -15340,30 +15553,32 @@ async function handleInbound(
|
|
|
15340
15553
|
// network RTT) but not a user-perceived end-to-end measurement.
|
|
15341
15554
|
const inboundReceivedAt = Date.now()
|
|
15342
15555
|
|
|
15343
|
-
//
|
|
15344
|
-
//
|
|
15345
|
-
//
|
|
15346
|
-
//
|
|
15347
|
-
//
|
|
15348
|
-
|
|
15349
|
-
//
|
|
15350
|
-
// inbound event advances it. A fresh-turn inbound transitions the
|
|
15351
|
-
// machine idle→in_turn; reading after the emit would see THIS
|
|
15352
|
-
// message's own just-started turn and self-block it (the same
|
|
15353
|
-
// self-block hazard the claudeBusyKeys snapshot below guards). When
|
|
15354
|
-
// the kill-switch is off this is null and the gate uses the legacy
|
|
15556
|
+
// PR3b-cutover: snapshot the machine's in-turn state AT RECEIPT, before
|
|
15557
|
+
// this handler emits the `inbound` event (that emit is DEFERRED below to
|
|
15558
|
+
// the delivery-commit point — search DEFERRED_INBOUND_EMIT). A fresh-turn
|
|
15559
|
+
// inbound transitions the machine idle→in_turn; reading after the emit
|
|
15560
|
+
// would see THIS message's own just-started turn and self-block it (the
|
|
15561
|
+
// same self-block hazard the claudeBusyKeys snapshot below guards). Null
|
|
15562
|
+
// when the kill-switch is off, in which case the gate uses the legacy
|
|
15355
15563
|
// claudeBusyKeys read.
|
|
15564
|
+
//
|
|
15565
|
+
// WHY THE INBOUND EMIT IS DEFERRED (overlord /usage wedge, 2026-07-08):
|
|
15566
|
+
// the `inbound` event drives the now-AUTHORITATIVE turn-in-flight gate
|
|
15567
|
+
// (turnInFlightForGate → isMachineInTurn). Emitting it HERE — at handler
|
|
15568
|
+
// entry, before the intercept gauntlet below (permission-reply, /auth
|
|
15569
|
+
// paste-back, interrupt-empty, secret-detect drop, …) — drove the machine
|
|
15570
|
+
// into `bridge_alive_in_turn` for messages that then EARLY-RETURN as an
|
|
15571
|
+
// intercept and never become a turn. No delivery, no claudeBusyKeys mark,
|
|
15572
|
+
// and critically no `turnEnd` ever fires, so the machine held the gate
|
|
15573
|
+
// closed until the 5-min TTL tick force-cleared it — buffering every
|
|
15574
|
+
// subsequent inbound (including /usage) the whole time. That is exactly
|
|
15575
|
+
// the dangerous `machine_over_holds` divergence gate-parity-probe.ts
|
|
15576
|
+
// flags. The imperative claudeBusyKeys tracker got it right (never marked
|
|
15577
|
+
// busy for the intercepted message); the machine was mis-fed. Fix: emit
|
|
15578
|
+
// `inbound` only once the message clears every intercept and reaches the
|
|
15579
|
+
// deliver-or-buffer decision, keeping the machine in lockstep with the
|
|
15580
|
+
// imperative delivery lifecycle it models.
|
|
15356
15581
|
const machineInTurnAtReceipt = isDeliveryCutoverEnabled() ? isMachineInTurn() : null
|
|
15357
|
-
shadowEmit({
|
|
15358
|
-
kind: 'inbound',
|
|
15359
|
-
key: _shadowKey,
|
|
15360
|
-
msg: {
|
|
15361
|
-
msgId: ctx.message?.message_id ?? 0,
|
|
15362
|
-
isSteering: false, // refined in PR 3 — for now shadow conservatively classifies as non-steering
|
|
15363
|
-
payload: null,
|
|
15364
|
-
},
|
|
15365
|
-
at: Date.now(),
|
|
15366
|
-
})
|
|
15367
15582
|
|
|
15368
15583
|
// #1556 self-blocking fix (v0.12.22): snapshot the live turn-state
|
|
15369
15584
|
// BEFORE the fresh-turn branch (line ~7357) sets activeTurnStartedAt
|
|
@@ -16560,6 +16775,29 @@ async function handleInbound(
|
|
|
16560
16775
|
return
|
|
16561
16776
|
}
|
|
16562
16777
|
|
|
16778
|
+
// DEFERRED_INBOUND_EMIT — drive the delivery state machine's `inbound`
|
|
16779
|
+
// event HERE, not at handler entry. Every intercept/early-return above
|
|
16780
|
+
// (permission-reply, /auth paste-back, interrupt-empty, secret-detect
|
|
16781
|
+
// drop, drop/pair) has been passed, so any message reaching this point is
|
|
16782
|
+
// a genuine turn the imperative code is about to deliver or buffer. That
|
|
16783
|
+
// keeps the machine's authoritative turn-in-flight state in lockstep with
|
|
16784
|
+
// the imperative delivery it models and can never be advanced into
|
|
16785
|
+
// `bridge_alive_in_turn` by a message that never becomes a turn — the
|
|
16786
|
+
// overlord /usage wedge of 2026-07-08 (see machineInTurnAtReceipt above).
|
|
16787
|
+
// isSteering is now the real classification (computed at ~line 16289), so
|
|
16788
|
+
// the machine correctly distinguishes a mid-turn steer (delivered, no new
|
|
16789
|
+
// turn) from a fresh turn.
|
|
16790
|
+
shadowEmit({
|
|
16791
|
+
kind: 'inbound',
|
|
16792
|
+
key: statusKey(chat_id, messageThreadId) as _ChatKey,
|
|
16793
|
+
msg: {
|
|
16794
|
+
msgId: msgId ?? 0,
|
|
16795
|
+
isSteering,
|
|
16796
|
+
payload: null,
|
|
16797
|
+
},
|
|
16798
|
+
at: Date.now(),
|
|
16799
|
+
})
|
|
16800
|
+
|
|
16563
16801
|
// PR2 obligation-ledger OPEN — BEFORE the buffer-until-idle / deliver split so
|
|
16564
16802
|
// a mid-turn cross-topic inbound (the 715 case) is tracked whether it is
|
|
16565
16803
|
// buffered or delivered now. Idempotent + gated; no-op when the flag is off.
|
|
@@ -16569,21 +16807,32 @@ async function handleInbound(
|
|
|
16569
16807
|
effectiveText,
|
|
16570
16808
|
})
|
|
16571
16809
|
|
|
16572
|
-
|
|
16573
|
-
|
|
16574
|
-
|
|
16575
|
-
|
|
16576
|
-
|
|
16577
|
-
|
|
16578
|
-
|
|
16579
|
-
|
|
16580
|
-
|
|
16581
|
-
|
|
16582
|
-
|
|
16583
|
-
|
|
16584
|
-
|
|
16585
|
-
|
|
16586
|
-
|
|
16810
|
+
// #2917: read the gate LIVE (not the receipt snapshot) on the default path
|
|
16811
|
+
// so a sibling same-chat inbound delivered during THIS handler's async
|
|
16812
|
+
// lead-in is observed here. Reading `claudeBusyKeys` live is self-block-safe:
|
|
16813
|
+
// THIS inbound's own key is only added at delivery (below), never by the
|
|
16814
|
+
// fresh-turn init bundle — so the live read never sees its own key. The
|
|
16815
|
+
// delivery-machine cutover keeps its at-receipt machine snapshot (a live
|
|
16816
|
+
// machine read WOULD self-block, since the inbound event already advanced
|
|
16817
|
+
// the machine for this key). Kill switch (=0) restores the pure snapshot.
|
|
16818
|
+
const gateTurnInFlight =
|
|
16819
|
+
SERIALIZE_INBOUND_DELIVERY_ENABLED && machineInTurnAtReceipt == null
|
|
16820
|
+
? claudeBusyKeys.size > 0
|
|
16821
|
+
: turnInFlightAtReceipt
|
|
16822
|
+
const deliveryGate = reserveInboundDelivery({
|
|
16823
|
+
turnInFlight: gateTurnInFlight,
|
|
16824
|
+
isSteering,
|
|
16825
|
+
// Interrupt-marker carve-out (2026-05-24): the `!`-prefixed body
|
|
16826
|
+
// must bypass the "buffer-until-turn-complete" gate because the
|
|
16827
|
+
// SIGINT'd turn often doesn't emit turn_complete, leaving the
|
|
16828
|
+
// body stranded in pendingInboundBuffer indefinitely. The
|
|
16829
|
+
// `interrupt` const is computed at the start of handleInbound
|
|
16830
|
+
// (line ~7606) and remains in scope here. When the user fires
|
|
16831
|
+
// `!`-with-body, this delivers the body as a fresh inbound to
|
|
16832
|
+
// the freshly-killed bridge.
|
|
16833
|
+
isInterrupt: interrupt.isInterrupt,
|
|
16834
|
+
})
|
|
16835
|
+
if (deliveryGate.decision === 'buffer-until-idle') {
|
|
16587
16836
|
pendingInboundBuffer.push(selfAgent, inboundMsg)
|
|
16588
16837
|
process.stderr.write(
|
|
16589
16838
|
`telegram gateway: inbound held mid-turn agent=${selfAgent} ` +
|
|
@@ -16607,6 +16856,18 @@ async function handleInbound(
|
|
|
16607
16856
|
return
|
|
16608
16857
|
}
|
|
16609
16858
|
|
|
16859
|
+
// #2917: reserve the chat's busy key SYNCHRONOUSLY here — before the
|
|
16860
|
+
// composer-clear await below — so a concurrent same-chat inbound reaching
|
|
16861
|
+
// the LIVE gate above observes this in-flight delivery and buffers behind it
|
|
16862
|
+
// (per-chat FIFO). Without this, both handlers pass the gate during each
|
|
16863
|
+
// other's async lead-in and race to the bridge, reordering the replies.
|
|
16864
|
+
// Only fresh-turn deliveries reserve (steering/interrupt amend a running
|
|
16865
|
+
// turn and must not). Released below if the send misses (bridge offline).
|
|
16866
|
+
let reservedBusyKey: string | null = null
|
|
16867
|
+
if (deliveryGate.reserve && SERIALIZE_INBOUND_DELIVERY_ENABLED && machineInTurnAtReceipt == null) {
|
|
16868
|
+
reservedBusyKey = markClaudeBusyForInbound(inboundMsg)
|
|
16869
|
+
}
|
|
16870
|
+
|
|
16610
16871
|
// Pre-send composer clear (the marko wedge). The inbound is about to be
|
|
16611
16872
|
// delivered as an MCP `notifications/claude/channel` notification, which
|
|
16612
16873
|
// the unmodified CLI appends into its composer and auto-submits ONLY when
|
|
@@ -16636,7 +16897,10 @@ async function handleInbound(
|
|
|
16636
16897
|
|
|
16637
16898
|
const delivered = ipcServer.sendToAgent(selfAgent, inboundMsg)
|
|
16638
16899
|
if (delivered) {
|
|
16639
|
-
|
|
16900
|
+
// Reuse the key reserved synchronously above (#2917) when present, else
|
|
16901
|
+
// mark now — markClaudeBusyForInbound is idempotent (lockstep re-stamp),
|
|
16902
|
+
// so a re-mark is safe and returns the same chat key.
|
|
16903
|
+
const busyKey = reservedBusyKey ?? markClaudeBusyForInbound(inboundMsg)
|
|
16640
16904
|
// Track until claude acks via `enqueue` (the marko drop-wedge): if no ack
|
|
16641
16905
|
// lands, the message stranded in the composer and the sweep re-delivers
|
|
16642
16906
|
// it. Track ONLY messages that produce an `enqueue` to ack against —
|
|
@@ -16658,6 +16922,15 @@ async function handleInbound(
|
|
|
16658
16922
|
}
|
|
16659
16923
|
}
|
|
16660
16924
|
if (!delivered) {
|
|
16925
|
+
// #2917: the synchronous reservation assumed delivery; the send missed
|
|
16926
|
+
// (bridge offline), so release it in lockstep — otherwise the orphaned
|
|
16927
|
+
// busy key would gate every subsequent inbound into the buffer until the
|
|
16928
|
+
// orphan reaper clears it. The message itself is buffered below, so FIFO
|
|
16929
|
+
// is still preserved (it drains in order on the next bridge register).
|
|
16930
|
+
if (reservedBusyKey != null) {
|
|
16931
|
+
claudeBusyKeys.delete(reservedBusyKey)
|
|
16932
|
+
claudeBusyKeySince.delete(reservedBusyKey)
|
|
16933
|
+
}
|
|
16661
16934
|
// Only persist fresh user turns to the durable spool. Steering / `!`
|
|
16662
16935
|
// interrupt / empty bodies are mid-turn amendments or no-ops that would
|
|
16663
16936
|
// arrive orphaned if replayed as a fresh turn after a restart — drop them
|
|
@@ -18223,7 +18496,7 @@ interface ModelDepsRestartContext {
|
|
|
18223
18496
|
}
|
|
18224
18497
|
|
|
18225
18498
|
function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & ModelCommandDeps {
|
|
18226
|
-
|
|
18499
|
+
const deps: ModelMenuDeps & ModelCommandDeps = {
|
|
18227
18500
|
discover: (a) => discoverModels(a),
|
|
18228
18501
|
discoverSrModels: async () => {
|
|
18229
18502
|
// /model/info lives on the ROOT proxy (model-mapped surface), NOT the
|
|
@@ -18329,7 +18602,25 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
|
|
|
18329
18602
|
)
|
|
18330
18603
|
}
|
|
18331
18604
|
},
|
|
18605
|
+
/**
|
|
18606
|
+
* Session-only switch TO an sr-* (LiteLLM/OpenRouter) model. claude's
|
|
18607
|
+
* native `/model` picker rejects unknown sr-* ids, so we can't inject.
|
|
18608
|
+
* Write the token to the `.session-model-override` carrier file (start.sh
|
|
18609
|
+
* consumes it on the next boot and launches `claude --model <token>`), set
|
|
18610
|
+
* the in-memory session-model so /status stays honest across the restart
|
|
18611
|
+
* window, then run the SAME restart dispatch as scheduleRestart above.
|
|
18612
|
+
*/
|
|
18613
|
+
scheduleModelRelaunch: async (model: string, reason: string) => {
|
|
18614
|
+
const agentDir = resolveAgentDirFromEnv()
|
|
18615
|
+
if (!agentDir) throw new Error('agent dir unresolvable — cannot write session-model carrier')
|
|
18616
|
+
// Carrier: single line, token + newline, no quoting (start.sh strips
|
|
18617
|
+
// whitespace and shape-gates). One-shot — consumed on the next boot.
|
|
18618
|
+
writeFileSync(join(agentDir, '.session-model-override'), `${model}\n`, 'utf8')
|
|
18619
|
+
activeSessionModelOverride = model
|
|
18620
|
+
await deps.scheduleRestart(reason)
|
|
18621
|
+
},
|
|
18332
18622
|
}
|
|
18623
|
+
return deps
|
|
18333
18624
|
}
|
|
18334
18625
|
|
|
18335
18626
|
function modelMenuReplyMarkup(reply: ModelMenuReply): InlineKeyboard | undefined {
|
|
@@ -19804,25 +20095,21 @@ async function runQuotaWatch(opts: { bootTick?: boolean } = {}): Promise<void> {
|
|
|
19804
20095
|
}
|
|
19805
20096
|
} catch (err) {
|
|
19806
20097
|
process.stderr.write(`telegram gateway: quota-watch: probe for crossing accounts failed: ${err}\n`)
|
|
19807
|
-
|
|
19808
|
-
|
|
19809
|
-
|
|
19810
|
-
|
|
19811
|
-
|
|
19812
|
-
|
|
19813
|
-
|
|
19814
|
-
|
|
19815
|
-
|
|
19816
|
-
process.stderr.write(`telegram gateway: quota-watch state persist failed: ${saveErr}\n`)
|
|
19817
|
-
}
|
|
20098
|
+
// A quota notification must never carry numbers we could not verify
|
|
20099
|
+
// live. Leave the crossing accounts' state untouched — the
|
|
20100
|
+
// transition re-evaluates (and re-probes) on the next 15-min tick.
|
|
20101
|
+
// Persist any reconciles already applied, then bail.
|
|
20102
|
+
if (reconciledCount > 0) {
|
|
20103
|
+
try {
|
|
20104
|
+
saveQuotaWatchState(stateDir, mutatedState)
|
|
20105
|
+
} catch (saveErr) {
|
|
20106
|
+
process.stderr.write(`telegram gateway: quota-watch state persist failed: ${saveErr}\n`)
|
|
19818
20107
|
}
|
|
19819
|
-
process.stderr.write(
|
|
19820
|
-
`telegram gateway: quota-watch: deferring ${pendingTransitions.length} notification(s) until probe succeeds\n`,
|
|
19821
|
-
)
|
|
19822
|
-
return
|
|
19823
20108
|
}
|
|
19824
|
-
|
|
19825
|
-
|
|
20109
|
+
process.stderr.write(
|
|
20110
|
+
`telegram gateway: quota-watch: deferring ${pendingTransitions.length} notification(s) until probe succeeds\n`,
|
|
20111
|
+
)
|
|
20112
|
+
return
|
|
19826
20113
|
}
|
|
19827
20114
|
|
|
19828
20115
|
// Build final notifications, enriching the snapshot with fresh probe
|
|
@@ -19872,7 +20159,7 @@ async function runQuotaWatch(opts: { bootTick?: boolean } = {}): Promise<void> {
|
|
|
19872
20159
|
// State normalised by the time of the probe — don't notify.
|
|
19873
20160
|
continue
|
|
19874
20161
|
}
|
|
19875
|
-
} else
|
|
20162
|
+
} else {
|
|
19876
20163
|
// No verified fresh data for this account (per-account probe failure
|
|
19877
20164
|
// or label missing from the batch result). Same rule as the batch
|
|
19878
20165
|
// throw above: never send unverified numbers. State untouched —
|
|
@@ -23385,14 +23672,41 @@ bot.on('callback_query:data', async ctx => {
|
|
|
23385
23672
|
// We track whether we applied the interim edit so we can skip the
|
|
23386
23673
|
// toastOnly short-circuit if we did — a toastOnly return after the interim
|
|
23387
23674
|
// edit would leave the menu stuck button-less.
|
|
23388
|
-
|
|
23675
|
+
// sr-* TARGET tap: switch TO a non-Claude (LiteLLM/OpenRouter) model.
|
|
23676
|
+
// Parity with the text `/model sr-*` path — claude's native picker rejects
|
|
23677
|
+
// unknown sr-* ids, so an in-place inject can't set them. Carry the token
|
|
23678
|
+
// across a graceful restart (the `.session-model-override` carrier) and
|
|
23679
|
+
// relaunch `claude --model sr-*`. Session-only; reverts to the configured
|
|
23680
|
+
// default on the next restart. The sr-* → Claude direction is handled below
|
|
23681
|
+
// via the SELECT/alias outcome + isSrToClaudeTransition.
|
|
23389
23682
|
if (data.startsWith(MODEL_CALLBACK_SR)) {
|
|
23390
|
-
const
|
|
23683
|
+
const srName = data.slice(MODEL_CALLBACK_SR.length)
|
|
23684
|
+
const srLabel = escapeHtmlForTg(srFriendlyLabel(srName))
|
|
23685
|
+
if (!isValidModelArg(srName)) {
|
|
23686
|
+
await ctx
|
|
23687
|
+
.editMessageText(richMessage('❌ Invalid model name'), { reply_markup: { inline_keyboard: [] } })
|
|
23688
|
+
.catch(() => {})
|
|
23689
|
+
return
|
|
23690
|
+
}
|
|
23391
23691
|
await ctx
|
|
23392
|
-
.editMessageText(
|
|
23692
|
+
.editMessageText(
|
|
23693
|
+
richMessage(`🔄 Switching session to **${srLabel}** — restarting (~30s). _Session-only; reverts to the configured default on the next restart._`),
|
|
23694
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
23695
|
+
)
|
|
23393
23696
|
.catch(() => {})
|
|
23394
|
-
|
|
23697
|
+
try {
|
|
23698
|
+
await modelDeps.scheduleModelRelaunch(srName, `user: /model ${srName} (session-only relaunch, menu)`)
|
|
23699
|
+
} catch (err) {
|
|
23700
|
+
await ctx
|
|
23701
|
+
.editMessageText(
|
|
23702
|
+
richMessage(`❌ Could not switch to **${srLabel}**: ${escapeHtmlForTg((err as Error)?.message ?? String(err))}`),
|
|
23703
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
23704
|
+
)
|
|
23705
|
+
.catch(() => {})
|
|
23706
|
+
}
|
|
23707
|
+
return
|
|
23395
23708
|
}
|
|
23709
|
+
const didInterimSrEdit = false
|
|
23396
23710
|
try {
|
|
23397
23711
|
const prevSessionModel = activeSessionModelOverride
|
|
23398
23712
|
const outcome = await handleModelMenuCallback(data, modelDeps)
|
|
@@ -26376,6 +26690,73 @@ void (async () => {
|
|
|
26376
26690
|
}
|
|
26377
26691
|
} catch {}
|
|
26378
26692
|
|
|
26693
|
+
// ─── Session-model re-hydration + LiteLLM-down alert (session relaunch) ───
|
|
26694
|
+
//
|
|
26695
|
+
// start.sh writes the EFFECTIVE launched model to `.active-session-model`
|
|
26696
|
+
// on every boot (the model actually passed to `claude --model`). Re-hydrate
|
|
26697
|
+
// the in-memory session-model override from it so `/status` and the welcome
|
|
26698
|
+
// card stay honest after a session-relaunch restart. Only treat it as an
|
|
26699
|
+
// override when it differs from the configured/default model — a plain boot
|
|
26700
|
+
// on the configured model leaves the override null.
|
|
26701
|
+
//
|
|
26702
|
+
// Also consume the `.session-model-alert` sentinel: start.sh drops it when
|
|
26703
|
+
// it had to DROP an sr-* override because LiteLLM was unreachable at boot
|
|
26704
|
+
// (booting on the configured default instead of 4xx-ing against Anthropic).
|
|
26705
|
+
// We turn it into a loud Telegram message to the operator, then delete it.
|
|
26706
|
+
try {
|
|
26707
|
+
const smAgentDir = resolveAgentDirFromEnv()
|
|
26708
|
+
if (smAgentDir) {
|
|
26709
|
+
const activePath = join(smAgentDir, '.active-session-model')
|
|
26710
|
+
if (existsSync(activePath)) {
|
|
26711
|
+
try {
|
|
26712
|
+
const launched = readFileSync(activePath, 'utf8').trim()
|
|
26713
|
+
const configured = (() => {
|
|
26714
|
+
type AgentListResp = { agents: Array<{ name: string; model?: string | null }> }
|
|
26715
|
+
const d = switchroomExecJson<AgentListResp>(['agent', 'list'])
|
|
26716
|
+
const raw = d?.agents?.find(a => a.name === getMyAgentName())?.model ?? null
|
|
26717
|
+
// Resolve through the SAME resolver start.sh's scaffold uses, so an
|
|
26718
|
+
// unset (`null`) or `model: "default"` config value maps to the
|
|
26719
|
+
// switchroom default model id — matching the EFFECTIVE model start.sh
|
|
26720
|
+
// wrote to `.active-session-model`. Comparing the raw (unresolved)
|
|
26721
|
+
// value would flag every ordinary restart of a default-model agent as
|
|
26722
|
+
// a phantom session override.
|
|
26723
|
+
return resolveMainModel(raw ?? undefined)
|
|
26724
|
+
})()
|
|
26725
|
+
activeSessionModelOverride =
|
|
26726
|
+
launched.length > 0 && launched !== configured ? launched : null
|
|
26727
|
+
} catch { /* leave override as-is on a bad read */ }
|
|
26728
|
+
}
|
|
26729
|
+
|
|
26730
|
+
const alertPath = join(smAgentDir, '.session-model-alert')
|
|
26731
|
+
if (existsSync(alertPath)) {
|
|
26732
|
+
let alertText: string | null = null
|
|
26733
|
+
try {
|
|
26734
|
+
alertText = readFileSync(alertPath, 'utf8').trim()
|
|
26735
|
+
} catch { alertText = null }
|
|
26736
|
+
try { unlinkSync(alertPath) } catch { /* best-effort */ }
|
|
26737
|
+
if (alertText && alertText.length > 0) {
|
|
26738
|
+
// Notify EVERY operator, not just allowFrom[0]. Each send is wrapped
|
|
26739
|
+
// in its own catch so one operator's failure (blocked bot, bad chat
|
|
26740
|
+
// id) never stops the rest, and the outer boot flow never crashes.
|
|
26741
|
+
const operators = loadAccess().allowFrom
|
|
26742
|
+
for (const operator of operators) {
|
|
26743
|
+
if (!operator) continue
|
|
26744
|
+
void lockedBot.api
|
|
26745
|
+
.sendMessage(operator, `⚠️ ${alertText}`)
|
|
26746
|
+
.catch((err: unknown) =>
|
|
26747
|
+
process.stderr.write(
|
|
26748
|
+
`telegram gateway: session-model alert send failed for ${operator}: ${(err as Error)?.message ?? String(err)}\n`,
|
|
26749
|
+
),
|
|
26750
|
+
)
|
|
26751
|
+
}
|
|
26752
|
+
process.stderr.write(`telegram gateway: session-model: LiteLLM-down override drop — ${alertText}\n`)
|
|
26753
|
+
}
|
|
26754
|
+
}
|
|
26755
|
+
}
|
|
26756
|
+
} catch (err) {
|
|
26757
|
+
process.stderr.write(`telegram gateway: session-model re-hydration failed: ${(err as Error)?.message ?? String(err)}\n`)
|
|
26758
|
+
}
|
|
26759
|
+
|
|
26379
26760
|
// Credit-exhaustion watcher (#348). Reads `<agentDir>/.claude/.claude.json`
|
|
26380
26761
|
// for `cachedExtraUsageDisabledReason`. Fires a Telegram notification
|
|
26381
26762
|
// on transition into / out of fatal billing states (out_of_credits,
|
|
@@ -26593,6 +26974,20 @@ void (async () => {
|
|
|
26593
26974
|
ownedWorktreeCwds({
|
|
26594
26975
|
self: process.env.SWITCHROOM_AGENT_NAME,
|
|
26595
26976
|
listRecords: listWorktreeRecords,
|
|
26977
|
+
// Durable, non-env identity fallback (#1116 / #2893): when
|
|
26978
|
+
// SWITCHROOM_AGENT_NAME is somehow unset, derive this
|
|
26979
|
+
// agent's own identity from its own directory so worktree
|
|
26980
|
+
// ownership still resolves (env is only the fast path).
|
|
26981
|
+
// `watcherAgentDir` is guaranteed non-null in this branch
|
|
26982
|
+
// (the whole watcher is gated on it above). Kill-switch
|
|
26983
|
+
// SWITCHROOM_WORKTREE_IDENTITY_FALLBACK=0 restores the
|
|
26984
|
+
// pre-fix env-only behaviour.
|
|
26985
|
+
agentDir:
|
|
26986
|
+
process.env.SWITCHROOM_WORKTREE_IDENTITY_FALLBACK === '0'
|
|
26987
|
+
? undefined
|
|
26988
|
+
: watcherAgentDir,
|
|
26989
|
+
log: (msg) =>
|
|
26990
|
+
process.stderr.write(`telegram gateway: ${msg}\n`),
|
|
26596
26991
|
}),
|
|
26597
26992
|
// Bug 0 fix: previously omitted, leaving the watcher unable to
|
|
26598
26993
|
// write liveness/stall/turn_end updates to the registry DB.
|