switchroom 0.18.13 → 0.18.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +49 -9
- package/dist/auth-broker/index.js +152 -46
- package/dist/cli/autoaccept-poll.js +23 -0
- package/dist/cli/drive-write-pretool.mjs +24 -1
- package/dist/cli/foreground-hog-pretool.mjs +264 -0
- package/dist/cli/notion-write-pretool.mjs +0 -1
- package/dist/cli/switchroom.js +1185 -1072
- package/dist/host-control/main.js +53 -52
- package/dist/vault/approvals/kernel-server.js +16 -13
- package/dist/vault/broker/server.js +672 -669
- package/package.json +1 -1
- package/profiles/coding/CLAUDE.md.hbs +2 -0
- package/profiles/default/CLAUDE.md.hbs +2 -0
- package/skills/switchroom-architecture/telegram.md +0 -1
- package/telegram-plugin/auth-snapshot-format.ts +37 -5
- package/telegram-plugin/auto-fallback-fleet.ts +29 -1
- package/telegram-plugin/bridge/bridge.ts +2 -0
- package/telegram-plugin/dist/bridge/bridge.js +23 -0
- package/telegram-plugin/dist/gateway/gateway.js +765 -67
- package/telegram-plugin/dist/server.js +24 -1
- package/telegram-plugin/gateway/auth-broker-client.ts +1 -0
- package/telegram-plugin/gateway/auth-command.ts +14 -0
- package/telegram-plugin/gateway/forward-origin.ts +235 -0
- package/telegram-plugin/gateway/gateway.ts +270 -10
- package/telegram-plugin/gateway/throttle-tier-wiring.ts +268 -0
- package/telegram-plugin/history.ts +55 -6
- package/telegram-plugin/model-unavailable.ts +234 -2
- package/telegram-plugin/render/rich-render.ts +40 -32
- package/telegram-plugin/runtime-metrics.ts +31 -0
- package/telegram-plugin/session-tail.ts +14 -2
- package/telegram-plugin/stream-controller.ts +3 -2
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +72 -0
- package/telegram-plugin/tests/forward-origin.test.ts +309 -0
- package/telegram-plugin/tests/history.test.ts +157 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +187 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +55 -0
- package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +6 -4
- package/telegram-plugin/tests/render/rich-render.test.ts +41 -22
- package/telegram-plugin/tests/runtime-metrics.test.ts +24 -0
- package/telegram-plugin/tests/single-mode-stream-reply.test.ts +5 -3
- package/telegram-plugin/tests/status-accent.test.ts +5 -3
- package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +20 -20
- package/telegram-plugin/tests/stream-reply-handler.test.ts +5 -2
- package/telegram-plugin/tests/throttle-tier-wiring.test.ts +290 -0
- package/telegram-plugin/tests/throttle-tier.test.ts +454 -0
- package/telegram-plugin/throttle-tier.ts +323 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +8 -7
|
@@ -94,6 +94,13 @@ import {
|
|
|
94
94
|
buildExtraAttachmentMeta,
|
|
95
95
|
resolveCoalesceMaxAttachments,
|
|
96
96
|
} from './coalesce-attachments.js'
|
|
97
|
+
import {
|
|
98
|
+
parseForwardOrigin,
|
|
99
|
+
dedupeForwardOrigins,
|
|
100
|
+
buildForwardOriginMeta,
|
|
101
|
+
forwardOriginDateIso,
|
|
102
|
+
type ForwardOriginInfo,
|
|
103
|
+
} from './forward-origin.js'
|
|
97
104
|
import { StatusReactionController } from '../status-reactions.js'
|
|
98
105
|
import { DeferredDoneReactions } from '../reaction-defer.js'
|
|
99
106
|
import { createWorkerActivityFeed, isWorkerActivityFeedEnabled } from '../worker-activity-feed.js'
|
|
@@ -310,7 +317,15 @@ import { recordOperatorEvent } from '../operator-events-history.js'
|
|
|
310
317
|
import {
|
|
311
318
|
formatModelUnavailableCard,
|
|
312
319
|
resolveModelUnavailableFromOperatorEvent,
|
|
320
|
+
type ModelUnavailableDetection,
|
|
313
321
|
} from '../model-unavailable.js'
|
|
322
|
+
import {
|
|
323
|
+
build429ClassifiedMetric,
|
|
324
|
+
classify429Detail,
|
|
325
|
+
decideThrottleTier,
|
|
326
|
+
throttleRetryInPlaceMaxMs,
|
|
327
|
+
} from '../throttle-tier.js'
|
|
328
|
+
import { createThrottleTierRunner } from './throttle-tier-wiring.js'
|
|
314
329
|
import { runFleetAutoFallback, renderFallbackFailureNotice, evaluateFallbackFailureNotice, evaluateAllBlockedNotice, type FallbackFailureNoticeState, type FallbackAllBlockedNoticeState } from '../auto-fallback-fleet.js'
|
|
315
330
|
import { startRestartWatchdog } from './restart-watchdog.js'
|
|
316
331
|
import { validateStringArray } from './access-validator.js'
|
|
@@ -7587,6 +7602,14 @@ type CoalescePayload = {
|
|
|
7587
7602
|
attachment?: AttachmentMeta
|
|
7588
7603
|
// Set only by `merge`: the 2nd..Nth attachments folded into this turn.
|
|
7589
7604
|
extraAttachments?: CoalesceAttachment[]
|
|
7605
|
+
// Forwarded-message origin of THIS entry (parsed at enqueue time from
|
|
7606
|
+
// message.forward_origin). merged.ctx is the LAST entry's ctx, so a
|
|
7607
|
+
// coalesced burst would otherwise lose the earlier entries' origins —
|
|
7608
|
+
// each entry carries its own.
|
|
7609
|
+
forwardOrigin?: ForwardOriginInfo
|
|
7610
|
+
// Set only by `merge`: the distinct origins of the whole burst, arrival
|
|
7611
|
+
// order, deduped (a single-origin album collapses to one entry).
|
|
7612
|
+
forwardOrigins?: ForwardOriginInfo[]
|
|
7590
7613
|
}
|
|
7591
7614
|
|
|
7592
7615
|
// Count of attachment-bearing entries currently buffered per coalesce key.
|
|
@@ -7619,6 +7642,11 @@ const inboundCoalescer = createInboundCoalescer<CoalescePayload>({
|
|
|
7619
7642
|
(e) => e.downloadImage != null || e.attachment != null,
|
|
7620
7643
|
coalesceMaxAttachments(),
|
|
7621
7644
|
)
|
|
7645
|
+
// Distinct forwarded-message origins across the burst, arrival order.
|
|
7646
|
+
// A forwarded album (one origin, N parts) collapses to a single entry
|
|
7647
|
+
// so the attrs are emitted once; a burst forwarded from two different
|
|
7648
|
+
// senders keeps both (numbered forwarded_from_2 siblings downstream).
|
|
7649
|
+
const forwardOrigins = dedupeForwardOrigins(entries.map((e) => e.forwardOrigin))
|
|
7622
7650
|
return {
|
|
7623
7651
|
// Drop empty texts (e.g. caption-less album parts) so the join doesn't
|
|
7624
7652
|
// emit blank lines between attachments.
|
|
@@ -7629,6 +7657,7 @@ const inboundCoalescer = createInboundCoalescer<CoalescePayload>({
|
|
|
7629
7657
|
extraAttachments: extras.length > 0
|
|
7630
7658
|
? extras.map((e) => ({ downloadImage: e.downloadImage, attachment: e.attachment }))
|
|
7631
7659
|
: undefined,
|
|
7660
|
+
forwardOrigins: forwardOrigins.length > 0 ? forwardOrigins : undefined,
|
|
7632
7661
|
}
|
|
7633
7662
|
},
|
|
7634
7663
|
onFlush: (key, merged) => {
|
|
@@ -7639,6 +7668,7 @@ const inboundCoalescer = createInboundCoalescer<CoalescePayload>({
|
|
|
7639
7668
|
merged.downloadImage,
|
|
7640
7669
|
merged.attachment,
|
|
7641
7670
|
merged.extraAttachments,
|
|
7671
|
+
merged.forwardOrigins,
|
|
7642
7672
|
)
|
|
7643
7673
|
},
|
|
7644
7674
|
})
|
|
@@ -7659,6 +7689,117 @@ const inboundCoalescer = createInboundCoalescer<CoalescePayload>({
|
|
|
7659
7689
|
function emitGatewayOperatorEvent(event: OperatorEvent): void {
|
|
7660
7690
|
const { agent, kind } = event
|
|
7661
7691
|
|
|
7692
|
+
// ── 429 throttle tier (operator spec: "retry in place under 5 min, else
|
|
7693
|
+
// mark + failover, honest reset messaging") ────────────────────────────
|
|
7694
|
+
// A terminal TRANSIENT ACCOUNT-scoped 429 — kind `rate-limited` carrying
|
|
7695
|
+
// wording that affirms the account's own rate limit ("would exceed your
|
|
7696
|
+
// account's rate limit") — is decided BEFORE the per-kind cooldown gate:
|
|
7697
|
+
// every hit must reach the broker's mark-throttled escalation counter, and
|
|
7698
|
+
// the >threshold failover ACTION must fire, even when the user-facing card
|
|
7699
|
+
// is cooldown-suppressed (a calm 529 card two minutes earlier arms that
|
|
7700
|
+
// cooldown). Server-side transients (529 / "server is temporarily…") fall
|
|
7701
|
+
// through to the existing calm rate-limited card unchanged — an
|
|
7702
|
+
// account-scoped throttle would be the wrong action for a server-wide
|
|
7703
|
+
// condition.
|
|
7704
|
+
//
|
|
7705
|
+
// Classification is three-way (classify429Detail, throttle-tier.ts):
|
|
7706
|
+
// only `account-scoped` may enter the throttle tier. `litellm-local` —
|
|
7707
|
+
// the LiteLLM proxy's OWN tpm/rpm/router limiter tripping before the
|
|
7708
|
+
// request reached Anthropic — takes the calm path with NO broker mark and
|
|
7709
|
+
// NO failover: the condition is proxy-local and says nothing about the
|
|
7710
|
+
// account. Every rate-limited event ALSO emits one
|
|
7711
|
+
// `rate_limit_429_classified` runtime metric (PostHog + JSONL), fired
|
|
7712
|
+
// here — before the cooldown gate — so operators can correlate
|
|
7713
|
+
// account-scoped 429s with fleet TPM even when the card is suppressed.
|
|
7714
|
+
let throttleEscalation: ModelUnavailableDetection | null = null
|
|
7715
|
+
let escalationFired = false
|
|
7716
|
+
const rateLimit429Classification =
|
|
7717
|
+
kind === 'rate-limited' ? classify429Detail(event.detail) : null
|
|
7718
|
+
if (rateLimit429Classification != null && rateLimit429Classification !== 'account-scoped') {
|
|
7719
|
+
// litellm-local / generic-transient: the existing calm rate-limited
|
|
7720
|
+
// path (fall through to renderOperatorEvent below). NO broker
|
|
7721
|
+
// mark-throttled, NO throttle-tier runner, NO failover — for a
|
|
7722
|
+
// proxy-local cap trip those would bench an account that was never
|
|
7723
|
+
// touched.
|
|
7724
|
+
emitRuntimeMetric(
|
|
7725
|
+
build429ClassifiedMetric({
|
|
7726
|
+
agent,
|
|
7727
|
+
detail: event.detail,
|
|
7728
|
+
classification: rateLimit429Classification,
|
|
7729
|
+
action: 'calm',
|
|
7730
|
+
now: Date.now(),
|
|
7731
|
+
}),
|
|
7732
|
+
)
|
|
7733
|
+
if (rateLimit429Classification === 'litellm-local') {
|
|
7734
|
+
process.stderr.write(
|
|
7735
|
+
`telegram gateway: 429 classified litellm-proxy-local agent=${agent} — ` +
|
|
7736
|
+
`calm path, no account attribution, no failover\n`,
|
|
7737
|
+
)
|
|
7738
|
+
}
|
|
7739
|
+
}
|
|
7740
|
+
if (rateLimit429Classification === 'account-scoped') {
|
|
7741
|
+
const throttleDecision = decideThrottleTier({
|
|
7742
|
+
detail: event.detail,
|
|
7743
|
+
now: Date.now(),
|
|
7744
|
+
thresholdMs: throttleRetryInPlaceMaxMs(),
|
|
7745
|
+
})
|
|
7746
|
+
emitRuntimeMetric(
|
|
7747
|
+
build429ClassifiedMetric({
|
|
7748
|
+
agent,
|
|
7749
|
+
detail: event.detail,
|
|
7750
|
+
classification: 'account-scoped',
|
|
7751
|
+
// decideThrottleTier can't return 'none' for account-scoped wording;
|
|
7752
|
+
// map the two live actions onto the metric's vocabulary.
|
|
7753
|
+
action: throttleDecision.action === 'failover' ? 'failover' : 'throttle',
|
|
7754
|
+
now: Date.now(),
|
|
7755
|
+
}),
|
|
7756
|
+
)
|
|
7757
|
+
if (throttleDecision.action === 'throttle') {
|
|
7758
|
+
// Reset is near (≤ threshold) or unparseable (60s default): DO NOT
|
|
7759
|
+
// fail over. Record throttled_until broker-side, post ONE lightweight
|
|
7760
|
+
// notice (per-account + fleet-wide dedup), nudge a retry after the
|
|
7761
|
+
// reset. The runner owns the sequencing (throttle-tier-wiring.ts).
|
|
7762
|
+
process.stderr.write(
|
|
7763
|
+
`telegram gateway: throttle-tier staying-put agent=${agent} ` +
|
|
7764
|
+
`until=${new Date(throttleDecision.throttledUntilMs).toISOString()} ` +
|
|
7765
|
+
`parsedReset=${throttleDecision.resetParsed}\n`,
|
|
7766
|
+
)
|
|
7767
|
+
try {
|
|
7768
|
+
recordOperatorEvent(event)
|
|
7769
|
+
} catch { /* history is best-effort */ }
|
|
7770
|
+
void throttleTierRunner.fire(
|
|
7771
|
+
agent,
|
|
7772
|
+
throttleDecision.throttledUntilMs,
|
|
7773
|
+
throttleDecision.resetParsed,
|
|
7774
|
+
)
|
|
7775
|
+
return
|
|
7776
|
+
}
|
|
7777
|
+
if (throttleDecision.action === 'failover') {
|
|
7778
|
+
// Reset beyond the retry-in-place threshold: the account is benched
|
|
7779
|
+
// until then anyway, so escalate to the standard mark-exhausted +
|
|
7780
|
+
// fleet-failover machinery — honest 'rate_limited' wording, parsed
|
|
7781
|
+
// reset as the mark expiry. The ACTION fires HERE, before the
|
|
7782
|
+
// per-kind card cooldown below can swallow it (the fleetFallbackGate
|
|
7783
|
+
// provides the action-level dedup); only the card stays
|
|
7784
|
+
// cooldown-bounded. The 'rate-limit' trigger tells the dispatcher to
|
|
7785
|
+
// trust this terminal parsed-reset signal over the utilization probe
|
|
7786
|
+
// (a transient 429 negates the usage-limit reading, so utilization
|
|
7787
|
+
// typically looks healthy — the idempotency guard would self-cancel
|
|
7788
|
+
// the swap).
|
|
7789
|
+
const resetAt = new Date(throttleDecision.resetAtMs)
|
|
7790
|
+
throttleEscalation = { kind: 'rate_limited', resetAt, raw: event.detail }
|
|
7791
|
+
if (wouldFireFleetAutoFallback()) {
|
|
7792
|
+
escalationFired = true
|
|
7793
|
+
void fireFleetAutoFallback(
|
|
7794
|
+
agent,
|
|
7795
|
+
resolveExhaustUntil(resetAt.getTime()),
|
|
7796
|
+
resetAt,
|
|
7797
|
+
'rate-limit',
|
|
7798
|
+
)
|
|
7799
|
+
}
|
|
7800
|
+
}
|
|
7801
|
+
}
|
|
7802
|
+
|
|
7662
7803
|
if (!shouldEmitOperatorEvent(agent, kind)) {
|
|
7663
7804
|
process.stderr.write(
|
|
7664
7805
|
`telegram gateway: operator-event suppressed (cooldown) agent=${agent} kind=${kind}\n`,
|
|
@@ -7687,7 +7828,10 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
|
|
|
7687
7828
|
// by the upstream classifier
|
|
7688
7829
|
// - detail string contains one of the model-unavailable text patterns
|
|
7689
7830
|
// (covers raw stderr that slipped past structured classification)
|
|
7690
|
-
|
|
7831
|
+
// Throttle-tier escalation (long-reset transient 429) takes precedence:
|
|
7832
|
+
// resolveModelUnavailableFromOperatorEvent deliberately returns null for a
|
|
7833
|
+
// bare rate-limited event, but this one is auto-fallback-eligible.
|
|
7834
|
+
const modelUnavailable = throttleEscalation ?? resolveModelUnavailableFromOperatorEvent(event)
|
|
7691
7835
|
let renderedText: string
|
|
7692
7836
|
let renderedKeyboard: ReturnType<typeof renderOperatorEvent>['keyboard'] | undefined
|
|
7693
7837
|
// #3031 PR 3 — when the card promises an in-flight auto-failover, record
|
|
@@ -7709,8 +7853,18 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
|
|
|
7709
7853
|
// it just produces a self-cancelling "probed healthy / Stale event?"
|
|
7710
7854
|
// loop on every 529. Overload is handled by Claude Code's own
|
|
7711
7855
|
// internal retry, not by switching accounts.
|
|
7712
|
-
|
|
7713
|
-
|
|
7856
|
+
// 'rate_limited' is the throttle-tier escalation (transient 429 whose
|
|
7857
|
+
// reset exceeds the retry-in-place threshold) — same failover mechanics
|
|
7858
|
+
// as a quota wall, honest wording on the card. Its fire already happened
|
|
7859
|
+
// ABOVE the cooldown gate (escalationFired) so the action can't be
|
|
7860
|
+
// swallowed by the per-kind card cooldown; the card here only reports it.
|
|
7861
|
+
const isAutoKind =
|
|
7862
|
+
modelUnavailable.kind === 'quota_exhausted' ||
|
|
7863
|
+
modelUnavailable.kind === 'rate_limited'
|
|
7864
|
+
const willActuallyFire =
|
|
7865
|
+
throttleEscalation != null
|
|
7866
|
+
? escalationFired
|
|
7867
|
+
: isAutoKind && wouldFireFleetAutoFallback()
|
|
7714
7868
|
process.stderr.write(
|
|
7715
7869
|
`telegram gateway: operator-event suppressing-raw-stderr-for-model-unavailable agent=${agent} kind=${kind} detected=${modelUnavailable.kind} autoKind=${isAutoKind} willFire=${willActuallyFire}\n`,
|
|
7716
7870
|
)
|
|
@@ -7734,9 +7888,14 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
|
|
|
7734
7888
|
// Pre-fix this called fireFleetAutoFallback(agent) with no until, so a
|
|
7735
7889
|
// weekly wall that surfaced as a 429 got markExhausted's ~5h default and
|
|
7736
7890
|
// the broker re-mirrored the still-walled account onto the fleet after 5h.
|
|
7737
|
-
|
|
7891
|
+
// Throttle-tier escalations (throttleEscalation != null) already fired
|
|
7892
|
+
// above the cooldown gate — don't double-fire here.
|
|
7893
|
+
if (willActuallyFire && throttleEscalation == null) {
|
|
7738
7894
|
const untilMs = resolveExhaustUntil(modelUnavailable.resetAt?.getTime())
|
|
7739
|
-
|
|
7895
|
+
// Thread the RAW parsed reset (distinct from the floor-applied untilMs)
|
|
7896
|
+
// so the fallback announcement can name it even when the live probe of
|
|
7897
|
+
// the old account fails — honest reset messaging on the enriched card.
|
|
7898
|
+
void fireFleetAutoFallback(agent, untilMs, modelUnavailable.resetAt)
|
|
7740
7899
|
}
|
|
7741
7900
|
} else {
|
|
7742
7901
|
try {
|
|
@@ -11374,7 +11533,13 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
11374
11533
|
(msg.resetAt == null ? ' (reset unparsed → +7d default)' : '') +
|
|
11375
11534
|
' — triggering fleet auto-fallback\n',
|
|
11376
11535
|
)
|
|
11377
|
-
void fireFleetAutoFallback(
|
|
11536
|
+
void fireFleetAutoFallback(
|
|
11537
|
+
msg.agentName,
|
|
11538
|
+
untilMs,
|
|
11539
|
+
// Enriched announcement: the sidecar-parsed reset (when present) keeps
|
|
11540
|
+
// the recovery line honest even when the old account's probe fails.
|
|
11541
|
+
msg.resetAt != null ? new Date(msg.resetAt) : undefined,
|
|
11542
|
+
)
|
|
11378
11543
|
},
|
|
11379
11544
|
|
|
11380
11545
|
// Issue #2971 — read-only wedge-watchdog probe: is there a live pending
|
|
@@ -18070,7 +18235,15 @@ async function handleInboundCoalesced(
|
|
|
18070
18235
|
ctx.message?.message_thread_id,
|
|
18071
18236
|
String(from.id),
|
|
18072
18237
|
)
|
|
18073
|
-
const result = inboundCoalescer.enqueue(key, {
|
|
18238
|
+
const result = inboundCoalescer.enqueue(key, {
|
|
18239
|
+
text,
|
|
18240
|
+
ctx,
|
|
18241
|
+
downloadImage,
|
|
18242
|
+
attachment,
|
|
18243
|
+
// Parsed HERE (not in merge) because merge only sees the last ctx —
|
|
18244
|
+
// each buffered entry keeps its own forwarded-message origin.
|
|
18245
|
+
forwardOrigin: parseForwardOrigin(ctx.message?.forward_origin),
|
|
18246
|
+
})
|
|
18074
18247
|
// Coalescing disabled (window <= 0): flush immediately, preserving any
|
|
18075
18248
|
// media this message carried.
|
|
18076
18249
|
if (result.bypass) return handleInbound(ctx, text, downloadImage, attachment)
|
|
@@ -18149,6 +18322,11 @@ async function handleInbound(
|
|
|
18149
18322
|
// resolved (photos downloaded) and surfaced as numbered meta fields
|
|
18150
18323
|
// (image_path_2, attachment_file_id_2, …) alongside the primary.
|
|
18151
18324
|
extraAttachments?: CoalesceAttachment[],
|
|
18325
|
+
// Forwarded-message origins for a coalesced burst (deduped, arrival
|
|
18326
|
+
// order — see the coalescer's merge). Left undefined by the direct /
|
|
18327
|
+
// bypass call sites; handleInbound then parses THIS ctx's
|
|
18328
|
+
// forward_origin itself, so single-message forwards work on every path.
|
|
18329
|
+
coalescedForwardOrigins?: ForwardOriginInfo[],
|
|
18152
18330
|
): Promise<void> {
|
|
18153
18331
|
markIdleActivity() // any inbound resets the idle auto-clear timer + re-arms
|
|
18154
18332
|
const isTopicMessage = ctx.message?.is_topic_message ?? false
|
|
@@ -19367,6 +19545,19 @@ async function handleInbound(
|
|
|
19367
19545
|
: undefined
|
|
19368
19546
|
const replyToTextEscaped = formatReplyToText(replyToTextRaw, REPLY_TO_TEXT_MAX)
|
|
19369
19547
|
|
|
19548
|
+
// Forwarded-message origin context. `forward_origin` is stamped by
|
|
19549
|
+
// Telegram's servers (Bot API 7.0+) — the forwarding user cannot forge
|
|
19550
|
+
// it via the message body, so it rides the trusted attrs lane and is
|
|
19551
|
+
// NEVER folded into the body text. Coalesced bursts pass their deduped
|
|
19552
|
+
// per-entry origins; direct/bypass paths parse this ctx's own origin.
|
|
19553
|
+
// Same raw-vs-escaped split as reply_to_text: `forwardOrigins` carries
|
|
19554
|
+
// raw (truncated) names for SQLite, `buildForwardOriginMeta` escapes at
|
|
19555
|
+
// the channel-meta boundary.
|
|
19556
|
+
const forwardOrigins = coalescedForwardOrigins
|
|
19557
|
+
?? dedupeForwardOrigins([parseForwardOrigin(ctx.message?.forward_origin)])
|
|
19558
|
+
const forwardOriginMeta = buildForwardOriginMeta(forwardOrigins)
|
|
19559
|
+
const primaryForwardOrigin = forwardOrigins[0]
|
|
19560
|
+
|
|
19370
19561
|
if (HISTORY_ENABLED) {
|
|
19371
19562
|
try {
|
|
19372
19563
|
recordInbound({
|
|
@@ -19380,6 +19571,11 @@ async function handleInbound(
|
|
|
19380
19571
|
attachment_kind: attachment?.kind,
|
|
19381
19572
|
reply_to_message_id: replyToMessageId ?? null,
|
|
19382
19573
|
reply_to_text: replyToText ?? null,
|
|
19574
|
+
forwarded_from: primaryForwardOrigin?.name ?? null,
|
|
19575
|
+
forwarded_from_type: primaryForwardOrigin?.type ?? null,
|
|
19576
|
+
forwarded_from_id: primaryForwardOrigin?.id != null ? String(primaryForwardOrigin.id) : null,
|
|
19577
|
+
forwarded_date: forwardOriginDateIso(primaryForwardOrigin),
|
|
19578
|
+
forwarded_message_id: primaryForwardOrigin?.messageId ?? null,
|
|
19383
19579
|
})
|
|
19384
19580
|
} catch (err) {
|
|
19385
19581
|
process.stderr.write(`telegram gateway: history recordInbound failed: ${err}\n`)
|
|
@@ -19485,6 +19681,11 @@ async function handleInbound(
|
|
|
19485
19681
|
// Use the XML-escaped form for the meta — the raw form is in the
|
|
19486
19682
|
// SQLite buffer for verbatim retrieval via get_recent_messages.
|
|
19487
19683
|
...(replyToTextEscaped != null && replyToTextEscaped.length > 0 ? { reply_to_text: replyToTextEscaped } : {}),
|
|
19684
|
+
// Forwarded-message origin (server-stamped, attrs-only — see above).
|
|
19685
|
+
// forwarded_from / forwarded_from_type / forwarded_from_id /
|
|
19686
|
+
// forwarded_date, plus numbered _2.. siblings for a multi-origin
|
|
19687
|
+
// burst. Names are XML-escaped inside buildForwardOriginMeta.
|
|
19688
|
+
...forwardOriginMeta,
|
|
19488
19689
|
// queued="true" when mid-turn with no steer prefix (new default), or
|
|
19489
19690
|
// with explicit /queue or /q prefix (legacy alias).
|
|
19490
19691
|
...((isQueuedMidTurn || isQueuedPrefix) ? { queued: 'true' } : {}),
|
|
@@ -23203,9 +23404,14 @@ function wouldFireFleetAutoFallback(): boolean {
|
|
|
23203
23404
|
* so the user sees the outcome inline with the original "Model
|
|
23204
23405
|
* unavailable" card.
|
|
23205
23406
|
*/
|
|
23206
|
-
async function fireFleetAutoFallback(
|
|
23407
|
+
async function fireFleetAutoFallback(
|
|
23408
|
+
triggerAgent: string,
|
|
23409
|
+
untilMs?: number,
|
|
23410
|
+
parsedResetAt?: Date,
|
|
23411
|
+
trigger?: 'rate-limit',
|
|
23412
|
+
): Promise<void> {
|
|
23207
23413
|
return fleetFallbackGate.fire(
|
|
23208
|
-
() => doFireFleetAutoFallback(triggerAgent, untilMs),
|
|
23414
|
+
() => doFireFleetAutoFallback(triggerAgent, untilMs, parsedResetAt, trigger),
|
|
23209
23415
|
(err) => {
|
|
23210
23416
|
process.stderr.write(
|
|
23211
23417
|
`telegram gateway: [fleet-fallback] error agent=${triggerAgent}: ${(err as Error)?.message ?? err}\n`,
|
|
@@ -23214,6 +23420,45 @@ async function fireFleetAutoFallback(triggerAgent: string, untilMs?: number): Pr
|
|
|
23214
23420
|
)
|
|
23215
23421
|
}
|
|
23216
23422
|
|
|
23423
|
+
// ─── 429 throttle tier — side-effect wiring ─────────────────────────────────
|
|
23424
|
+
// Decision + notice text live in throttle-tier.ts (pure); the SEQUENCING
|
|
23425
|
+
// (broker mark → fleet-deduped notice → jittered retry nudge with the
|
|
23426
|
+
// live-turn safety guards) lives in throttle-tier-wiring.ts so it is
|
|
23427
|
+
// unit-testable with injected deps. This block only binds the real gateway
|
|
23428
|
+
// dependencies.
|
|
23429
|
+
const throttleTierRunner = createThrottleTierRunner({
|
|
23430
|
+
agentName: process.env.SWITCHROOM_AGENT_NAME ?? '',
|
|
23431
|
+
getBrokerClient: () =>
|
|
23432
|
+
getAuthBrokerClient(process.env.SWITCHROOM_AGENT_NAME ?? ''),
|
|
23433
|
+
listNoticeChats: () => loadAccess().allowFrom,
|
|
23434
|
+
sendNotice: (chat_id, markdown) => {
|
|
23435
|
+
// Status notice, not the user's answer — silence the ping (same posture
|
|
23436
|
+
// as the fleet-fallback announcement).
|
|
23437
|
+
void swallowingApiCall(
|
|
23438
|
+
// allow-raw-bot-api: wrapped in swallowingApiCall (retry policy)
|
|
23439
|
+
() => bot.api.sendRichMessage(chat_id, richMessage(markdown), { disable_notification: true }),
|
|
23440
|
+
{ chat_id: String(chat_id), verb: 'throttle-tier:notify' },
|
|
23441
|
+
)
|
|
23442
|
+
},
|
|
23443
|
+
resumeDecide: (ts) => fleetFallbackResumeGate.decide(ts),
|
|
23444
|
+
newestActiveTurnStartedAtMs,
|
|
23445
|
+
turnInFlight: () => turnInFlightForGate(),
|
|
23446
|
+
deferRestartToTurnComplete: (agentName, reason) => {
|
|
23447
|
+
// Same lever the schedule_restart IPC uses: the restart drains at the
|
|
23448
|
+
// turn-complete gate (never SIGTERM-now under a live turn). Note the
|
|
23449
|
+
// pending-restart drain cap (PENDING_RESTART_DRAIN_CAP_MS) still bounds
|
|
23450
|
+
// a never-idling session — deliberate, matching scheduled restarts.
|
|
23451
|
+
process.stderr.write(
|
|
23452
|
+
`telegram gateway: [throttle-tier] restart deferred to turn-complete agent=${agentName} reason=${reason}\n`,
|
|
23453
|
+
)
|
|
23454
|
+
pendingRestarts.set(agentName, Date.now())
|
|
23455
|
+
},
|
|
23456
|
+
restartNow: (agentName, reason) => {
|
|
23457
|
+
triggerSelfRestart(agentName, reason)
|
|
23458
|
+
},
|
|
23459
|
+
log: (m) => process.stderr.write(`telegram gateway: ${m}\n`),
|
|
23460
|
+
})
|
|
23461
|
+
|
|
23217
23462
|
/**
|
|
23218
23463
|
* Broadcast a fleet-fallback FAILURE notice to every authorized chat.
|
|
23219
23464
|
*
|
|
@@ -23275,7 +23520,12 @@ function broadcastFleetFallbackFailure(triggerAgent: string, reason: string): vo
|
|
|
23275
23520
|
* user-visible announcement was broadcast). False on no-op /
|
|
23276
23521
|
* error / idempotent-skip — caller uses this to decide whether to
|
|
23277
23522
|
* arm the post-fire suppression window. */
|
|
23278
|
-
async function doFireFleetAutoFallback(
|
|
23523
|
+
async function doFireFleetAutoFallback(
|
|
23524
|
+
triggerAgent: string,
|
|
23525
|
+
untilMs?: number,
|
|
23526
|
+
parsedResetAt?: Date,
|
|
23527
|
+
trigger?: 'rate-limit',
|
|
23528
|
+
): Promise<boolean> {
|
|
23279
23529
|
try {
|
|
23280
23530
|
const client = await getAuthBrokerClient(triggerAgent)
|
|
23281
23531
|
if (!client) {
|
|
@@ -23324,6 +23574,16 @@ async function doFireFleetAutoFallback(triggerAgent: string, untilMs?: number):
|
|
|
23324
23574
|
},
|
|
23325
23575
|
triggerAgent,
|
|
23326
23576
|
tz,
|
|
23577
|
+
// Enriched card (429 throttle tier): the RAW reset parsed from the
|
|
23578
|
+
// error prose, so the announcement names when the old account frees
|
|
23579
|
+
// even when its live probe returned nothing.
|
|
23580
|
+
parsedResetAt,
|
|
23581
|
+
// Throttle-tier escalation: a terminal transient 429 with a long
|
|
23582
|
+
// parsed reset NEGATES the usage-limit reading, so the old account's
|
|
23583
|
+
// utilization probe typically classifies healthy — the idempotency
|
|
23584
|
+
// guard would self-cancel the swap ("probed healthy / Stale event?").
|
|
23585
|
+
// Trust the terminal parsed-reset signal over the utilization probe.
|
|
23586
|
+
rateLimitTrigger: trigger === 'rate-limit',
|
|
23327
23587
|
})
|
|
23328
23588
|
process.stderr.write(
|
|
23329
23589
|
`telegram gateway: [fleet-fallback] outcome=${outcome.kind} agent=${triggerAgent}` +
|