switchroom 0.19.26 → 0.19.27
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/git-agent-attribution-hook.sh +144 -0
- package/dist/agent-scheduler/index.js +55 -0
- package/dist/auth-broker/index.js +116 -6
- package/dist/cli/notion-write-pretool.mjs +55 -0
- package/dist/cli/switchroom.js +2055 -1193
- package/dist/host-control/main.js +117 -7
- package/dist/vault/approvals/kernel-server.js +114 -6
- package/dist/vault/broker/server.js +114 -6
- package/package.json +6 -2
- package/profiles/_base/cron-session.sh.hbs +8 -0
- package/profiles/_base/start.sh.hbs +105 -0
- package/telegram-plugin/card-layout.ts +328 -0
- package/telegram-plugin/dist/bridge/bridge.js +93 -1
- package/telegram-plugin/dist/gateway/gateway.js +2009 -1166
- package/telegram-plugin/dist/server.js +96 -1
- package/telegram-plugin/edit-flood-fuse.ts +637 -56
- package/telegram-plugin/flood-429-ledger.ts +526 -0
- package/telegram-plugin/flood-circuit-breaker.ts +18 -0
- package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
- package/telegram-plugin/gateway/gateway.ts +58 -68
- package/telegram-plugin/gateway/narrative-lane.ts +14 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
- package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
- package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
- package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
- package/telegram-plugin/gateway/status-pin-store.ts +58 -9
- package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
- package/telegram-plugin/llm-error-present.ts +61 -2
- package/telegram-plugin/model-unavailable.ts +8 -0
- package/telegram-plugin/operator-events.ts +72 -5
- package/telegram-plugin/outbound-class.ts +81 -0
- package/telegram-plugin/provider-credit.ts +237 -0
- package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
- package/telegram-plugin/send-gate.ts +24 -2
- package/telegram-plugin/status-no-truncate.ts +10 -48
- package/telegram-plugin/status-pin-driver.ts +33 -45
- package/telegram-plugin/status-pin.ts +18 -1
- package/telegram-plugin/tests/card-golden.test.ts +69 -0
- package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
- package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
- package/telegram-plugin/tests/card-variants.golden.txt +211 -0
- package/telegram-plugin/tests/card-variants.ts +366 -0
- package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +316 -0
- package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
- package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
- package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
- package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
- package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
- package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
- package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
- package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
- package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
- package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
- package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
- package/telegram-plugin/tests/status-pin.test.ts +56 -5
- package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
- package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
- package/telegram-plugin/tool-activity-summary.ts +239 -365
- package/telegram-plugin/uat/assertions.ts +22 -11
- package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
- package/telegram-plugin/worker-activity-feed.ts +105 -47
- package/vendor/hindsight-memory/CLAUDE.md +45 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
- package/vendor/hindsight-memory/scripts/recall.py +176 -7
- package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
- package/vendor/hindsight-memory/settings.json +1 -1
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* flood-reply-queue.ts — durably queue a USER-FACING reply that Telegram's
|
|
3
|
+
* flood window refused, instead of throwing its content away.
|
|
4
|
+
*
|
|
5
|
+
* The hole this closes (#3861). `retryApiCall` short-circuits BEFORE the wire
|
|
6
|
+
* when the persisted breaker reports a window longer than its in-process sleep
|
|
7
|
+
* ceiling, throwing the `FLOOD_WAIT_ACTIVE` marker (`retry-api-call.ts:198`,
|
|
8
|
+
* `:252`). That is the right call for the WIRE — nothing can succeed while the
|
|
9
|
+
* ban is open — but it is a bare `throw`, and the reply path's chunk-loop catch
|
|
10
|
+
* only re-wrapped it:
|
|
11
|
+
*
|
|
12
|
+
* reply failed after 0 of 1 chunk(s) sent: FLOOD_WAIT_ACTIVE
|
|
13
|
+
*
|
|
14
|
+
* The composed answer died in that string. Reproduced live on 2026-07-28: the
|
|
15
|
+
* error came back to the MCP caller and NO record appeared in the agent's
|
|
16
|
+
* `telegram/outbox/`. The durable-outbox work-loss guarantee was real for the
|
|
17
|
+
* outbox-delivered paths and false for the interactive reply path — so during a
|
|
18
|
+
* multi-hour ban the agent's actual answers to the operator were the one class
|
|
19
|
+
* of content that was silently lost.
|
|
20
|
+
*
|
|
21
|
+
* What this module does: classify the failure, and for anything that is not
|
|
22
|
+
* droppable (`cosmetic`) write the text into the SAME durable outbox the sweep
|
|
23
|
+
* already drains, under a CONTENT-derived nonce. The sweep (which since #3854
|
|
24
|
+
* defers while the window is open) delivers it when the window closes.
|
|
25
|
+
*
|
|
26
|
+
* Two design choices worth stating, because both are load-bearing:
|
|
27
|
+
*
|
|
28
|
+
* 1. CONTENT-derived nonce, not the turn nonce. `writeOutboxRecordAtomic` is
|
|
29
|
+
* idempotent per nonce and the sweep's delivered-keys journal is keyed by
|
|
30
|
+
* nonce, so a content nonce gives caller-retry dedup (same text ⇒ same file
|
|
31
|
+
* ⇒ one delivery, and after delivery the journal makes a re-queue a no-op)
|
|
32
|
+
* WITHOUT inheriting the turn nonce's failure mode: an earlier successful
|
|
33
|
+
* reply in the same turn journals `turn.turnId` as delivered, which would
|
|
34
|
+
* make a later flood-blocked answer queued under that same nonce
|
|
35
|
+
* `skip-journaled` — i.e. dropped. Never drop the answer.
|
|
36
|
+
*
|
|
37
|
+
* 2. `cosmetic` is NOT queued. A typing indicator or a card edit that missed
|
|
38
|
+
* its moment is worthless (and actively confusing) delivered hours later;
|
|
39
|
+
* the priority classes already in the tree say so. `critical` / `useful` /
|
|
40
|
+
* untagged are queued.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { sha256Hex, writeOutboxRecordAtomic, type OutboxRecord } from '../outbox.js'
|
|
44
|
+
import { isFloodWaitActiveError } from '../retry-api-call.js'
|
|
45
|
+
|
|
46
|
+
/** The `source` stamped on records this module writes (diagnostic only). */
|
|
47
|
+
export const FLOOD_QUEUED_SOURCE = 'flood-deferred-reply'
|
|
48
|
+
|
|
49
|
+
/** Priority classes as used by `retry-api-call.ts` / `outbound-class.ts`. */
|
|
50
|
+
export type QueuePriorityClass = 'critical' | 'useful' | 'cosmetic' | undefined
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* True when `err` is a Telegram flood rejection — the local `FLOOD_WAIT_ACTIVE`
|
|
54
|
+
* fail-fast, a raw 429, or a wrapper whose message carries either.
|
|
55
|
+
*
|
|
56
|
+
* Deliberately duck-typed as well as `instanceof`-checked: the marker error
|
|
57
|
+
* carries Telegram's own `{ error_code: 429, parameters: { retry_after } }`
|
|
58
|
+
* shape, several surfaces re-wrap it in a plain `Error`, and the reply path's
|
|
59
|
+
* own partial-failure contract wraps it in
|
|
60
|
+
* `reply failed after N of M chunk(s) sent: …`.
|
|
61
|
+
*/
|
|
62
|
+
export function isFloodRejection(err: unknown): boolean {
|
|
63
|
+
if (isFloodWaitActiveError(err)) return true
|
|
64
|
+
const e = err as { error_code?: number; parameters?: { retry_after?: number } } | null
|
|
65
|
+
if (e != null && typeof e === 'object') {
|
|
66
|
+
if (e.error_code === 429) return true
|
|
67
|
+
if (typeof e.parameters?.retry_after === 'number') return true
|
|
68
|
+
}
|
|
69
|
+
const msg = err instanceof Error ? err.message : String(err ?? '')
|
|
70
|
+
if (msg.includes('FLOOD_WAIT_ACTIVE')) return true
|
|
71
|
+
return /too many requests/i.test(msg) && /retry[ _-]?after/i.test(msg)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Telegram's stated `retry_after` (seconds) if the error carries one. */
|
|
75
|
+
export function floodRetryAfterSec(err: unknown): number | null {
|
|
76
|
+
const e = err as { retryAfterSec?: number; parameters?: { retry_after?: number } } | null
|
|
77
|
+
if (e != null && typeof e === 'object') {
|
|
78
|
+
if (typeof e.retryAfterSec === 'number') return e.retryAfterSec
|
|
79
|
+
if (typeof e.parameters?.retry_after === 'number') return e.parameters.retry_after
|
|
80
|
+
}
|
|
81
|
+
const msg = err instanceof Error ? err.message : String(err ?? '')
|
|
82
|
+
const m = /retry[ _-]?after[^0-9]{0,4}(\d+)/i.exec(msg)
|
|
83
|
+
return m != null ? Number(m[1]) : null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Stable, content-derived outbox nonce for a flood-deferred send. Same
|
|
88
|
+
* chat + thread + text ⇒ same nonce ⇒ exactly one delivery however many times
|
|
89
|
+
* the caller retries. Prefixed so a record's provenance is obvious on disk.
|
|
90
|
+
*/
|
|
91
|
+
export function floodQueueNonce(
|
|
92
|
+
chatId: string,
|
|
93
|
+
threadId: number | null,
|
|
94
|
+
text: string,
|
|
95
|
+
): string {
|
|
96
|
+
return `flood-${sha256Hex(`${chatId}\u0000${threadId ?? '_'}\u0000${text}`).slice(0, 40)}`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface FloodQueueInput {
|
|
100
|
+
/** The error that killed the send. */
|
|
101
|
+
err: unknown
|
|
102
|
+
chatId: string
|
|
103
|
+
threadId: number | null
|
|
104
|
+
/** The UNDELIVERED text (for a partial failure, only the chunks that never landed). */
|
|
105
|
+
text: string
|
|
106
|
+
/** Priority class of the send. `cosmetic` is never queued. */
|
|
107
|
+
priorityClass?: QueuePriorityClass
|
|
108
|
+
/** Per-session origin chat, for the sweep's F2 scoped routing fallback. */
|
|
109
|
+
originChatId?: string | null
|
|
110
|
+
originThreadId?: number | null
|
|
111
|
+
createdAt?: number
|
|
112
|
+
stateDir?: string
|
|
113
|
+
/** Injected for tests; defaults to the real atomic outbox writer. */
|
|
114
|
+
write?: (record: OutboxRecord, stateDir?: string) => boolean
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface FloodQueueResult {
|
|
118
|
+
turnNonce: string
|
|
119
|
+
retryAfterSec: number | null
|
|
120
|
+
/** Human-readable outcome for the MCP caller, so the model does NOT recompose. */
|
|
121
|
+
notice: string
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Queue a flood-blocked send to the durable outbox.
|
|
126
|
+
*
|
|
127
|
+
* Returns `null` — meaning "not handled, let the caller throw" — when the error
|
|
128
|
+
* is not a flood rejection, when the class is `cosmetic` (legitimately
|
|
129
|
+
* droppable), when there is no text left to deliver, or when the disk write
|
|
130
|
+
* failed. A `null` return must never be mistaken for "delivered".
|
|
131
|
+
*/
|
|
132
|
+
export function queueFloodBlockedReply(input: FloodQueueInput): FloodQueueResult | null {
|
|
133
|
+
const { err, chatId, threadId, text } = input
|
|
134
|
+
if (!isFloodRejection(err)) return null
|
|
135
|
+
if (input.priorityClass === 'cosmetic') return null
|
|
136
|
+
if (chatId === '' || text === '') return null
|
|
137
|
+
|
|
138
|
+
const turnNonce = floodQueueNonce(chatId, threadId, text)
|
|
139
|
+
const createdAt = input.createdAt ?? Date.now()
|
|
140
|
+
const record: OutboxRecord = {
|
|
141
|
+
turnNonce,
|
|
142
|
+
chatId,
|
|
143
|
+
threadId,
|
|
144
|
+
text,
|
|
145
|
+
textSha256: sha256Hex(text),
|
|
146
|
+
createdAt,
|
|
147
|
+
source: FLOOD_QUEUED_SOURCE,
|
|
148
|
+
...(input.originChatId != null ? { originChatId: input.originChatId } : {}),
|
|
149
|
+
...(input.originThreadId != null ? { originThreadId: input.originThreadId } : {}),
|
|
150
|
+
}
|
|
151
|
+
const write = input.write ?? writeOutboxRecordAtomic
|
|
152
|
+
if (!write(record, input.stateDir)) return null
|
|
153
|
+
|
|
154
|
+
const retryAfterSec = floodRetryAfterSec(err)
|
|
155
|
+
const when =
|
|
156
|
+
retryAfterSec != null && retryAfterSec > 0
|
|
157
|
+
? ` Telegram's flood window has ~${retryAfterSec}s left`
|
|
158
|
+
: ' A Telegram flood window is open'
|
|
159
|
+
return {
|
|
160
|
+
turnNonce,
|
|
161
|
+
retryAfterSec,
|
|
162
|
+
notice:
|
|
163
|
+
`reply QUEUED (not yet visible to the user):${when}, so this answer was written ` +
|
|
164
|
+
`to the durable outbox and will be delivered automatically when the window ` +
|
|
165
|
+
`closes. Do NOT resend it — a resend is deduplicated, and every extra call ` +
|
|
166
|
+
`extends the ban. (outbox nonce ${turnNonce})`,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -180,7 +180,7 @@ import {
|
|
|
180
180
|
import { fmtLocalStamp, resolveEnvTimezone, renderLogTimestampsLocal } from '../shared/local-time.js'
|
|
181
181
|
import { StatusReactionController } from '../status-reactions.js'
|
|
182
182
|
import { DeferredDoneReactions } from '../reaction-defer.js'
|
|
183
|
-
import { createWorkerActivityFeed, isWorkerActivityFeedEnabled } from '../worker-activity-feed.js'
|
|
183
|
+
import { createWorkerActivityFeed, isWorkerActivityFeedEnabled, workerFeedEditPriorityClass } from '../worker-activity-feed.js'
|
|
184
184
|
import {
|
|
185
185
|
detectMemoryLegibilityEvent,
|
|
186
186
|
isMemoryLegibilityEnabled,
|
|
@@ -195,8 +195,8 @@ import {
|
|
|
195
195
|
renderConsolidationLine,
|
|
196
196
|
} from '../consolidation-legibility.js'
|
|
197
197
|
import type { WebhookGatewayRecord } from '../../src/web/webhook-gateway-record.js'
|
|
198
|
-
import {
|
|
199
|
-
import type { PinState, DesiredPin } from '../status-pin.js'
|
|
198
|
+
import { executePinLeg, type PinBotApi } from '../status-pin-driver.js'
|
|
199
|
+
import type { PinState, DesiredPin, PinLegAction } from '../status-pin.js'
|
|
200
200
|
import { PinRightsCache } from '../status-pin.js'
|
|
201
201
|
import { formatTurnLifecycle, detectStatusSurfaceDegraded } from './status-surface-log.js'
|
|
202
202
|
import { parseSourceMessageId } from './source-message-id.js'
|
|
@@ -294,7 +294,7 @@ import {
|
|
|
294
294
|
retryWithThreadFallback,
|
|
295
295
|
isFloodWaitActiveError,
|
|
296
296
|
} from '../retry-api-call.js'
|
|
297
|
-
import { installEditFloodFuse } from '../edit-flood-fuse.js'
|
|
297
|
+
import { installEditFloodFuse, editFloodFuseConfigFromEnv } from '../edit-flood-fuse.js'
|
|
298
298
|
import { createSendGate, sendGateConfigFromEnv, isSendGateShed } from '../send-gate.js'
|
|
299
299
|
import { createStatsLogger, createFloodWindowObserver } from '../send-gate-observability.js'
|
|
300
300
|
import { installTgPostLogger, installRichMarkdownGuard, withTgPostTags } from '../shared/bot-runtime.js'
|
|
@@ -637,7 +637,7 @@ import type { HostdRequest, HostdResponse } from '../../src/host-control/protoco
|
|
|
637
637
|
import type { AgentAudit } from '../welcome-text.js'
|
|
638
638
|
import { shouldSweepChatAtBoot } from './boot-sweep-filter.js'
|
|
639
639
|
import { createBootSweepGate, runBootPinSweepSteps } from './boot-sweep-gate.js'
|
|
640
|
-
import { runStatusPinReconcile } from './status-pin-retarget.js'
|
|
640
|
+
import { runStatusPinReconcile, type StatusPinClaim } from './status-pin-retarget.js'
|
|
641
641
|
import { createPeriodicSweepGuard } from './periodic-sweep-guard.js'
|
|
642
642
|
import { withDeadline } from './with-deadline.js'
|
|
643
643
|
import { createStatusPinApi, type PinCapableBot, type RobustApiSeam } from './status-pin-api.js'
|
|
@@ -691,6 +691,7 @@ import {
|
|
|
691
691
|
} from './queued-card-store.js'
|
|
692
692
|
import {
|
|
693
693
|
decideWorkerPinReaps,
|
|
694
|
+
groupPinStatus,
|
|
694
695
|
storeOnlyWorkerPinCandidates,
|
|
695
696
|
WORKER_PIN_TTL_MS_DEFAULT,
|
|
696
697
|
} from './worker-pin-reaper.js'
|
|
@@ -5865,7 +5866,7 @@ const redactAuthCodeApi = {
|
|
|
5865
5866
|
* flood sleep and added a pre-call gate for LONG open windows. This wrapper
|
|
5866
5867
|
* pre-dates neither mechanism nor fights them — it sits under the emitter's own
|
|
5867
5868
|
* flood gate, which short-circuits earlier still (a typing ping during a ban
|
|
5868
|
-
* never even reaches the retry layer
|
|
5869
|
+
* never even reaches the retry layer; #3853 adds the READ hook here too).
|
|
5869
5870
|
*/
|
|
5870
5871
|
// No `log` hook: retryApiCall's flood line reads "waiting Ns", which would be a
|
|
5871
5872
|
// lie here — we never wait, we drop. And the error the caller finally sees names
|
|
@@ -5875,7 +5876,7 @@ const redactAuthCodeApi = {
|
|
|
5875
5876
|
// the real value is still in hand.
|
|
5876
5877
|
const recordTypingFloodWait = makeFloodWaitRecorder(FLOOD_STATE_PATH)
|
|
5877
5878
|
const nonEssentialApiCall = createRetryApiCall({
|
|
5878
|
-
maxRetries: 1,
|
|
5879
|
+
maxRetries: 1, floodWaitRemainingMs: probeFloodWaitRemainingMs, // #3853 read side
|
|
5879
5880
|
sleep: async () => {},
|
|
5880
5881
|
onFloodWait: (retryAfterSec) => {
|
|
5881
5882
|
recordTypingFloodWait(retryAfterSec)
|
|
@@ -8524,19 +8525,18 @@ const PIN_STATUS_WHILE_WORKING = (() => {
|
|
|
8524
8525
|
// It does NOT touch the reply / stream_reply send handlers (the v1 bug was send
|
|
8525
8526
|
// handlers unconditionally unpinning on every send) and runs NO polling
|
|
8526
8527
|
// watchdog / getChat().pinned_message reconciler.
|
|
8527
|
-
|
|
8528
|
+
// ONE registry, keyed by pinKey → { messageId, chatId, pinnedAt } (#3809).
|
|
8529
|
+
// This was three parallel Maps (state / chatIds / pinnedAt), written on the
|
|
8530
|
+
// same commit but free to diverge; a `wk:` claim that lost its chatId entry was
|
|
8531
|
+
// skipped by the mid-session reaper on every pass AND excluded from the durable
|
|
8532
|
+
// store-orphan net, i.e. silently unreapable until the next boot. One record
|
|
8533
|
+
// makes the invariant structural — see StatusPinClaim in status-pin-retarget.ts.
|
|
8534
|
+
//
|
|
8528
8535
|
// F2 serialization: same-pinKey reconciles run one-at-a-time via
|
|
8529
8536
|
// `withPinReconcileLock` (status-pin-store.ts — kept there so it's
|
|
8530
8537
|
// unit-testable) so each reads a fresh `prev`; see its doc for the stale-`prev`
|
|
8531
8538
|
// race it closes. Adds zero Telegram API calls (a serialized noop still no-ops).
|
|
8532
|
-
|
|
8533
|
-
// owned pins without threading the chat id through every call site. Written on
|
|
8534
|
-
// every desired-pinned reconcile, cleared alongside the state on unpin.
|
|
8535
|
-
const statusPinChatIds = new Map<string, string>()
|
|
8536
|
-
// Companion registry: pinKey → wall-clock ms the claim was FIRST taken (a
|
|
8537
|
-
// re-pin of the same key keeps the original timestamp). Feeds the TTL gate of
|
|
8538
|
-
// the mid-session `wk:` pin reaper (#3001); cleared alongside the state.
|
|
8539
|
-
const statusPinPinnedAt = new Map<string, number>()
|
|
8539
|
+
const statusPinClaims = new Map<string, StatusPinClaim>()
|
|
8540
8540
|
// Rights-aware negative cache (#3024): chats where an auto status-pin attempt
|
|
8541
8541
|
// failed with the permanent "not enough rights to manage pinned messages" 400.
|
|
8542
8542
|
// Per-process only — a restart clears it so a later-granted pin right re-enables
|
|
@@ -8660,7 +8660,7 @@ const TOOL_PIN_TTL_MS = (() => {
|
|
|
8660
8660
|
// mutation never rejects (persist is fail-open, load is fail-open).
|
|
8661
8661
|
function persistBannerRow(row: PersistedStatusPin | null): void {
|
|
8662
8662
|
if (!bannerPinPersistEnabled) return
|
|
8663
|
-
void mutateStatusPinRow(
|
|
8663
|
+
void mutateStatusPinRow( // allow-raw-pin-store: banner row bookkeeping — the slot banner has its own sanctioned driver; this only mirrors its outcome into the shared store file.
|
|
8664
8664
|
STATUS_PIN_STORE_PATH,
|
|
8665
8665
|
statusPinStoreFs,
|
|
8666
8666
|
BANNER_PIN_KEY,
|
|
@@ -8700,7 +8700,7 @@ async function statusPinBootCleanup(): Promise<void> {
|
|
|
8700
8700
|
const { cleared, retained, kept, total } = await runStatusPinBootCleanup({
|
|
8701
8701
|
path: STATUS_PIN_STORE_PATH,
|
|
8702
8702
|
fs: statusPinStoreFs,
|
|
8703
|
-
unpin: (chatId, messageId) => api.unpinChatMessage(chatId, messageId),
|
|
8703
|
+
unpin: (chatId, messageId) => api.unpinChatMessage(chatId, messageId), // allow-raw-pin: boot cleanup runs BEFORE any claim exists — it reconciles the durable rows themselves, so there is nothing to route through reconcileStatusPin.
|
|
8704
8704
|
})
|
|
8705
8705
|
if (total > 0) {
|
|
8706
8706
|
process.stderr.write(
|
|
@@ -8763,7 +8763,7 @@ async function activityCardBootReaper(): Promise<void> {
|
|
|
8763
8763
|
),
|
|
8764
8764
|
unpinCard: (record) =>
|
|
8765
8765
|
robustApiCall(
|
|
8766
|
-
() => lockedBot.api.unpinChatMessage(record.chatId, record.activityMessageId),
|
|
8766
|
+
() => lockedBot.api.unpinChatMessage(record.chatId, record.activityMessageId), // allow-raw-pin: activity-card boot reaper — unpins a card recovered from the CARD store, which has no status-pin claim to reconcile through.
|
|
8767
8767
|
{
|
|
8768
8768
|
chat_id: record.chatId,
|
|
8769
8769
|
...(record.threadId != null ? { threadId: record.threadId } : {}),
|
|
@@ -8942,12 +8942,12 @@ async function runMidSessionCardReaper(): Promise<void> {
|
|
|
8942
8942
|
// claim is tracked (e.g. claim already dropped out-of-band).
|
|
8943
8943
|
unpinCard: async (record) => {
|
|
8944
8944
|
const pinKey = `fg:${record.turnKey}`
|
|
8945
|
-
if (
|
|
8945
|
+
if (statusPinClaims.has(pinKey)) {
|
|
8946
8946
|
await reconcileStatusPin(pinKey, record.chatId, { pinned: false })
|
|
8947
8947
|
return true
|
|
8948
8948
|
}
|
|
8949
8949
|
return robustApiCall(
|
|
8950
|
-
() => lockedBot.api.unpinChatMessage(record.chatId, record.activityMessageId),
|
|
8950
|
+
() => lockedBot.api.unpinChatMessage(record.chatId, record.activityMessageId), // allow-raw-pin: activity-card mid-session reaper — same as the boot reaper: a card-store record, not a status-pin claim.
|
|
8951
8951
|
{
|
|
8952
8952
|
chat_id: record.chatId,
|
|
8953
8953
|
...(record.threadId != null ? { threadId: record.threadId } : {}),
|
|
@@ -8975,17 +8975,12 @@ async function runMidSessionCardReaper(): Promise<void> {
|
|
|
8975
8975
|
// the durable store row clear together.
|
|
8976
8976
|
if (WORKER_PIN_REAPER_ENABLED && PIN_STATUS_WHILE_WORKING) {
|
|
8977
8977
|
try {
|
|
8978
|
-
|
|
8979
|
-
|
|
8980
|
-
)
|
|
8981
|
-
|
|
8982
|
-
pinKey:
|
|
8983
|
-
|
|
8984
|
-
// A missing timestamp (should not happen — set on every claim)
|
|
8985
|
-
// degrades to "claimed just now": terminality can still reap it,
|
|
8986
|
-
// the TTL gate never can. Conservative, never a spurious unpin.
|
|
8987
|
-
pinnedAt: statusPinPinnedAt.get(k) ?? now,
|
|
8988
|
-
}))
|
|
8978
|
+
// One record per claim (#3809): chatId and pinnedAt are fields of the
|
|
8979
|
+
// claim, so neither can go missing and strand the key as unreapable.
|
|
8980
|
+
const inMemoryCandidates = [...statusPinClaims.entries()]
|
|
8981
|
+
.filter(([k]) => k.startsWith('wk:'))
|
|
8982
|
+
.map(([pinKey, claim]) => ({ pinKey, chatId: claim.chatId, pinnedAt: claim.pinnedAt }))
|
|
8983
|
+
const inMemoryKeys = new Set(inMemoryCandidates.map((c) => c.pinKey))
|
|
8989
8984
|
// #3001 durable group net: fold in `wk:` rows that live in the DURABLE
|
|
8990
8985
|
// store but have NO in-memory claim — the divergence window where the
|
|
8991
8986
|
// claim was lost but the Telegram pin + store row survive. These would
|
|
@@ -9012,8 +9007,11 @@ async function runMidSessionCardReaper(): Promise<void> {
|
|
|
9012
9007
|
// missed unpin → 'terminal' (reap now). The feed's own group-empty
|
|
9013
9008
|
// unpin is the primary path; this is the missed-unpin backstop.
|
|
9014
9009
|
if (agentId.startsWith('group:')) {
|
|
9015
|
-
|
|
9016
|
-
|
|
9010
|
+
// groupPinStatus distinguishes "no feed to ask" (→ 'unknown', TTL
|
|
9011
|
+
// only) from "the feed says this group is done" (→ 'terminal',
|
|
9012
|
+
// reap now). The `?.` shorthand conflated them and could unpin a
|
|
9013
|
+
// LIVE group pin during a feed-null window (#3811).
|
|
9014
|
+
return groupPinStatus(workerActivityFeed, agentId.slice('group:'.length))
|
|
9017
9015
|
}
|
|
9018
9016
|
if (turnsDb == null) return 'unknown'
|
|
9019
9017
|
try {
|
|
@@ -9044,7 +9042,7 @@ async function runMidSessionCardReaper(): Promise<void> {
|
|
|
9044
9042
|
// are best-effort/idempotent; a failed unpin still drops the row so
|
|
9045
9043
|
// the next boot's cleanup is the final backstop.
|
|
9046
9044
|
try {
|
|
9047
|
-
await statusPinApi().unpinChatMessage(reap.chatId, reap.messageId)
|
|
9045
|
+
await statusPinApi().unpinChatMessage(reap.chatId, reap.messageId) // allow-raw-pin: store-orphan reap — by definition there is NO in-memory claim, so this unpins the exact tracked message id (group-safe, never unpin-all).
|
|
9048
9046
|
} catch (err) {
|
|
9049
9047
|
process.stderr.write(
|
|
9050
9048
|
`telegram gateway: worker-pin reaper store-orphan unpin failed ` +
|
|
@@ -9052,7 +9050,7 @@ async function runMidSessionCardReaper(): Promise<void> {
|
|
|
9052
9050
|
`${(err as Error).message}\n`,
|
|
9053
9051
|
)
|
|
9054
9052
|
}
|
|
9055
|
-
await mutateStatusPinRow(
|
|
9053
|
+
await mutateStatusPinRow( // allow-raw-pin-store: drops the row for the store orphan unpinned immediately above; there is no claim to reconcile.
|
|
9056
9054
|
STATUS_PIN_STORE_PATH,
|
|
9057
9055
|
statusPinStoreFs,
|
|
9058
9056
|
reap.pinKey,
|
|
@@ -9142,14 +9140,15 @@ async function reconcileStatusPinInner(
|
|
|
9142
9140
|
// settled — always the true current claim. Closes the stale-`prev` race (a
|
|
9143
9141
|
// turn-end clear dropping the disk row under a flood-delayed open-pin) and the
|
|
9144
9142
|
// older duplicate-pin concern (two edits both reading prev=null).
|
|
9145
|
-
const
|
|
9143
|
+
const claim = statusPinClaims.get(pinKey)
|
|
9144
|
+
const prev: PinState | null = claim == null ? null : { messageId: claim.messageId }
|
|
9146
9145
|
|
|
9147
|
-
const runReconcileFrom = (from: PinState | null
|
|
9148
|
-
|
|
9146
|
+
const runReconcileFrom = (action: PinLegAction, from: PinState | null) =>
|
|
9147
|
+
executePinLeg({
|
|
9149
9148
|
api: statusPinApi(),
|
|
9150
9149
|
chatId,
|
|
9151
9150
|
prevState: from,
|
|
9152
|
-
|
|
9151
|
+
action,
|
|
9153
9152
|
rightsCache: statusPinRightsCache,
|
|
9154
9153
|
onPinRightsDisabled: (chat) => {
|
|
9155
9154
|
// Logged ONCE per chat per process (#3024). Every subsequent auto-pin
|
|
@@ -9179,11 +9178,7 @@ async function reconcileStatusPinInner(
|
|
|
9179
9178
|
? { path: STATUS_PIN_STORE_PATH, fs: statusPinStoreFs }
|
|
9180
9179
|
: null,
|
|
9181
9180
|
runPin: runReconcileFrom,
|
|
9182
|
-
|
|
9183
|
-
state: statusPinState,
|
|
9184
|
-
chatIds: statusPinChatIds,
|
|
9185
|
-
pinnedAt: statusPinPinnedAt,
|
|
9186
|
-
},
|
|
9181
|
+
claims: statusPinClaims,
|
|
9187
9182
|
})
|
|
9188
9183
|
}
|
|
9189
9184
|
|
|
@@ -9200,14 +9195,10 @@ async function reconcileStatusPinInner(
|
|
|
9200
9195
|
* crash / interrupt never leaves a permanent pin behind. Best-effort;
|
|
9201
9196
|
* clears the claim regardless of the unpin outcome (drop-on-unpin). */
|
|
9202
9197
|
async function unpinAllStatusPins(): Promise<void> {
|
|
9203
|
-
|
|
9204
|
-
|
|
9205
|
-
|
|
9206
|
-
|
|
9207
|
-
// Recover the chat id from the state map's companion key registry.
|
|
9208
|
-
const chatId = statusPinChatIds.get(key)
|
|
9209
|
-
if (chatId == null) { statusPinState.delete(key); statusPinPinnedAt.delete(key); continue }
|
|
9210
|
-
await reconcileStatusPin(key, chatId, { pinned: false })
|
|
9198
|
+
// The chat id is a FIELD of the claim (#3809), so every claim is unpinnable —
|
|
9199
|
+
// there is no "claim we know about but cannot address" branch to fall through.
|
|
9200
|
+
for (const [key, claim] of [...statusPinClaims.entries()]) {
|
|
9201
|
+
await reconcileStatusPin(key, claim.chatId, { pinned: false })
|
|
9211
9202
|
}
|
|
9212
9203
|
}
|
|
9213
9204
|
|
|
@@ -9226,14 +9217,14 @@ async function unpinAllStatusPins(): Promise<void> {
|
|
|
9226
9217
|
let dmPinSweepEligible = false
|
|
9227
9218
|
const dmPinSweeper: DmPinSweeper = createDmPinSweeper({
|
|
9228
9219
|
unpinAll: (chatId) =>
|
|
9229
|
-
robustApiCall(() => lockedBot.api.unpinAllChatMessages(chatId), {
|
|
9220
|
+
robustApiCall(() => lockedBot.api.unpinAllChatMessages(chatId), { // allow-raw-pin: DM-only boot sweep — clears pins this process cannot enumerate (pre-restart orphans) and immediately re-pins the live tracked ids below.
|
|
9230
9221
|
chat_id: chatId,
|
|
9231
9222
|
verb: 'dm-pin-sweep.unpin-all',
|
|
9232
9223
|
}),
|
|
9233
9224
|
pinSilent: (chatId, messageId) =>
|
|
9234
9225
|
robustApiCall(
|
|
9235
9226
|
() =>
|
|
9236
|
-
lockedBot.api.pinChatMessage(chatId, messageId, {
|
|
9227
|
+
lockedBot.api.pinChatMessage(chatId, messageId, { // allow-raw-pin: the re-pin half of the DM boot sweep — restores ids the sweep just cleared; the claims themselves are untouched.
|
|
9237
9228
|
disable_notification: true,
|
|
9238
9229
|
}),
|
|
9239
9230
|
{ chat_id: chatId, verb: 'dm-pin-sweep.repin' },
|
|
@@ -9248,8 +9239,8 @@ const dmPinSweeper: DmPinSweeper = createDmPinSweeper({
|
|
|
9248
9239
|
// degrades to in-memory-only. The sweeper dedupes.
|
|
9249
9240
|
liveTrackedMessageIds: (chatId) => {
|
|
9250
9241
|
const ids: number[] = []
|
|
9251
|
-
for (const
|
|
9252
|
-
if (
|
|
9242
|
+
for (const claim of statusPinClaims.values()) {
|
|
9243
|
+
if (claim.chatId === chatId) ids.push(claim.messageId)
|
|
9253
9244
|
}
|
|
9254
9245
|
if (statusPinPersistEnabled || bannerPinPersistEnabled || toolPinPersistEnabled) {
|
|
9255
9246
|
try {
|
|
@@ -13341,7 +13332,7 @@ async function executePinMessage(args: Record<string, unknown>): Promise<unknown
|
|
|
13341
13332
|
// failure the agent should see.
|
|
13342
13333
|
const pinMsgId = Number(args.message_id)
|
|
13343
13334
|
await robustApiCall(
|
|
13344
|
-
() => lockedBot.api.pinChatMessage(pinChatId, pinMsgId),
|
|
13335
|
+
() => lockedBot.api.pinChatMessage(pinChatId, pinMsgId), // allow-raw-pin: MCP `pin_message` tool — an explicit, agent-requested pin of an arbitrary message, not a progress surface with a claim.
|
|
13345
13336
|
{ chat_id: pinChatId, verb: 'pin_message' },
|
|
13346
13337
|
)
|
|
13347
13338
|
// An explicit pin succeeded here, so the bot demonstrably HAS pin rights in
|
|
@@ -13358,7 +13349,7 @@ async function executePinMessage(args: Record<string, unknown>): Promise<unknown
|
|
|
13358
13349
|
// failure must never fail the tool call the pin already landed for.
|
|
13359
13350
|
if (toolPinPersistEnabled) {
|
|
13360
13351
|
const toolPinKey = `tool:${pinChatId}:${pinMsgId}`
|
|
13361
|
-
void mutateStatusPinRow(STATUS_PIN_STORE_PATH, statusPinStoreFs, toolPinKey, {
|
|
13352
|
+
void mutateStatusPinRow(STATUS_PIN_STORE_PATH, statusPinStoreFs, toolPinKey, { // allow-raw-pin-store: records the TTL-scoped `tool:` row for the explicit pin above so the boot sweep can expire it.
|
|
13362
13353
|
pinKey: toolPinKey,
|
|
13363
13354
|
chatId: pinChatId,
|
|
13364
13355
|
messageId: pinMsgId,
|
|
@@ -22349,8 +22340,7 @@ const voiceHandlerDeps: VoiceHandlerDeps = {
|
|
|
22349
22340
|
// site (check-bot-api-wrapping stays satisfied — the raw call remains in
|
|
22350
22341
|
// gateway.ts, already inside the retry policy).
|
|
22351
22342
|
const pinnedMessageHandlerDeps: PinnedMessageHandlerDeps = {
|
|
22352
|
-
|
|
22353
|
-
statusPinChatIds,
|
|
22343
|
+
statusPinClaims,
|
|
22354
22344
|
deleteServiceMessage: (chatId, serviceMsgId) =>
|
|
22355
22345
|
robustApiCall(
|
|
22356
22346
|
() => lockedBot.api.deleteMessage(chatId, serviceMsgId),
|
|
@@ -22965,8 +22955,8 @@ async function initGatewayBot(): Promise<void> {
|
|
|
22965
22955
|
// outbound call can bypass (grammY has no route to the network that skips the
|
|
22966
22956
|
// transformer stack). Kill-switch SWITCHROOM_EDIT_FUSE=0; see edit-flood-fuse.ts.
|
|
22967
22957
|
installEditFloodFuse(bot, {
|
|
22968
|
-
|
|
22969
|
-
onTrip: (i) => process.stderr.write(`edit-flood-fuse ${i.action} method=${i.method} key=${i.key}\n`),
|
|
22958
|
+
...editFloodFuseConfigFromEnv(process.env),
|
|
22959
|
+
onTrip: (i) => process.stderr.write(`edit-flood-fuse ${i.action} method=${i.method} key=${i.key} class=${i.cls}\n`),
|
|
22970
22960
|
})
|
|
22971
22961
|
|
|
22972
22962
|
// Diagnostic update tap (#3300): one compact line per received update, logged
|
|
@@ -23986,7 +23976,7 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
|
|
|
23986
23976
|
)
|
|
23987
23977
|
return sent as { message_id: number }
|
|
23988
23978
|
},
|
|
23989
|
-
editMessageText: (cid, mid, text, editOpts) =>
|
|
23979
|
+
editMessageText: (cid, mid, text, editOpts, editMeta) =>
|
|
23990
23980
|
robustApiCall(
|
|
23991
23981
|
() =>
|
|
23992
23982
|
lockedBot.api.editMessageText(
|
|
@@ -23995,12 +23985,12 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
|
|
|
23995
23985
|
richMessage(text),
|
|
23996
23986
|
editOpts as Parameters<typeof lockedBot.api.editMessageText>[3],
|
|
23997
23987
|
),
|
|
23998
|
-
//
|
|
23999
|
-
//
|
|
23988
|
+
// Repaints COSMETIC, terminal frame USEFUL (#3848); messageId/
|
|
23989
|
+
// editPayload engage the floor + coalescing + no-op skip.
|
|
24000
23990
|
{
|
|
24001
23991
|
chat_id: cid,
|
|
24002
23992
|
verb: 'worker-feed',
|
|
24003
|
-
priorityClass:
|
|
23993
|
+
priorityClass: workerFeedEditPriorityClass(editMeta),
|
|
24004
23994
|
messageId: mid,
|
|
24005
23995
|
editPayload: text,
|
|
24006
23996
|
},
|
|
@@ -24058,7 +24048,7 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
|
|
|
24058
24048
|
if (messageId != null) {
|
|
24059
24049
|
void reconcileStatusPin(key, chatId, { pinned: true, messageId })
|
|
24060
24050
|
} else {
|
|
24061
|
-
const unpinChat = chatId ||
|
|
24051
|
+
const unpinChat = chatId || statusPinClaims.get(key)?.chatId
|
|
24062
24052
|
if (unpinChat != null && unpinChat.length > 0) {
|
|
24063
24053
|
void reconcileStatusPin(key, unpinChat, { pinned: false })
|
|
24064
24054
|
}
|
|
@@ -838,6 +838,20 @@ export function createNarrativeLane(deps: NarrativeLaneDeps) {
|
|
|
838
838
|
id,
|
|
839
839
|
)
|
|
840
840
|
}
|
|
841
|
+
// #3812 — release the status-pin claim BEFORE the message goes away,
|
|
842
|
+
// symmetric with the durable card-record drop above. `turn-end.ts` also
|
|
843
|
+
// unpins `fg:<statusKey>`, but that runs LATER: on the
|
|
844
|
+
// CLEAR_STATUS_ON_COMPLETION path the message is DELETED here, so by turn
|
|
845
|
+
// end the claim named a dead id and the unpin was a guaranteed-4xx API
|
|
846
|
+
// call every single turn. Worse, the durable status-pins.json row pointed
|
|
847
|
+
// at a deleted message for that whole window, so a crash there handed the
|
|
848
|
+
// next boot's sweep a row it would also fail to unpin — burning an
|
|
849
|
+
// attempt off the BOOT_UNPIN_MAX_ATTEMPTS forfeit ladder for nothing.
|
|
850
|
+
//
|
|
851
|
+
// AWAITED so the unpin (and its durable-row clear) is ordered strictly
|
|
852
|
+
// before the delete. turn-end stays the idempotent backstop: it no-ops
|
|
853
|
+
// once nothing is claimed for the key.
|
|
854
|
+
await reconcileStatusPin(`fg:${statusKey(chat, thread)}`, chat, { pinned: false })
|
|
841
855
|
if (CLEAR_STATUS_ON_COMPLETION) {
|
|
842
856
|
try {
|
|
843
857
|
await robustApiCall(
|
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
} from '../format.js'
|
|
53
53
|
import { richMessage } from '../rich-send.js'
|
|
54
54
|
import { journalExternalDelivery } from './outbox-sweep.js'
|
|
55
|
+
import { queueFloodBlockedReply } from './flood-reply-queue.js'
|
|
55
56
|
import { resolveChatIdFallback } from './chat-id-fallback.js'
|
|
56
57
|
import { isFinalAnswerReply, isSubstantiveFinalReply, shouldJournalReplySiteDelivery } from '../final-answer-detect.js'
|
|
57
58
|
import { decideOverPing, type OverPingDecision } from '../over-ping-safety-net.js'
|
|
@@ -1960,6 +1961,41 @@ export async function sendReply(
|
|
|
1960
1961
|
threadId = _sendResult.threadId
|
|
1961
1962
|
previewMessageId = _sendResult.previewMessageId
|
|
1962
1963
|
} catch (err) {
|
|
1964
|
+
// #3861 — a flood-blocked reply is QUEUED, never discarded. Pre-fix this
|
|
1965
|
+
// catch re-wrapped `FLOOD_WAIT_ACTIVE` into a string and threw the answer
|
|
1966
|
+
// away: `retryApiCall` fails fast BEFORE the wire while a long window is
|
|
1967
|
+
// open, so during a multi-hour ban EVERY answer the agent composed for the
|
|
1968
|
+
// operator was lost — the one content class the durable outbox did not
|
|
1969
|
+
// cover. The undelivered text goes into the same outbox the sweep drains
|
|
1970
|
+
// (it defers while the window is open, #3854) under a content-derived
|
|
1971
|
+
// nonce, so a caller retry cannot double-deliver. `cosmetic` sends are
|
|
1972
|
+
// still dropped by `queueFloodBlockedReply`; this site is `critical`.
|
|
1973
|
+
const queued = queueFloodBlockedReply({
|
|
1974
|
+
err,
|
|
1975
|
+
chatId: chat_id,
|
|
1976
|
+
threadId: threadId ?? null,
|
|
1977
|
+
// Partial failure: queue only the chunks that never landed, so the user
|
|
1978
|
+
// does not receive the already-delivered prefix a second time.
|
|
1979
|
+
text: sentIds.length === 0 ? text : chunks.slice(sentIds.length).join('\n\n'),
|
|
1980
|
+
priorityClass: 'critical',
|
|
1981
|
+
originChatId: turn?.sessionChatId ?? null,
|
|
1982
|
+
originThreadId: turn?.sessionThreadId ?? null,
|
|
1983
|
+
})
|
|
1984
|
+
if (queued != null) {
|
|
1985
|
+
process.stderr.write(
|
|
1986
|
+
`telegram gateway: reply flood-deferred — queued to outbox nonce=${queued.turnNonce} ` +
|
|
1987
|
+
`chat=${chat_id} chars=${text.length} sent=${sentIds.length}/${chunks.length}\n`,
|
|
1988
|
+
)
|
|
1989
|
+
// The obligation is DISCHARGED by the durable enqueue. Without this the
|
|
1990
|
+
// obligation tracker keeps re-prompting the agent to reply every few
|
|
1991
|
+
// minutes for the whole ban — dozens of forced turns it physically
|
|
1992
|
+
// cannot satisfy (and pre-#3861 each attempt destroyed its own answer).
|
|
1993
|
+
if (turn != null && isSubstantiveFinalReply({ text, disableNotification: modelDisableNotification })) {
|
|
1994
|
+
closeObligationOnSubstantiveReply(args, turn, replyRoutedOriginTurn)
|
|
1995
|
+
}
|
|
1996
|
+
// (the `finally` below stops the typing loop on this return path too)
|
|
1997
|
+
return { content: [{ type: 'text', text: queued.notice }] }
|
|
1998
|
+
}
|
|
1963
1999
|
const msg = err instanceof Error ? err.message : String(err)
|
|
1964
2000
|
throw new Error(`reply failed after ${sentIds.length} of ${chunks.length} chunk(s) sent: ${msg}`)
|
|
1965
2001
|
} finally {
|