switchroom 0.16.47 → 0.17.0
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 +3 -1
- package/dist/auth-broker/index.js +24 -8
- package/dist/cli/drive-write-pretool.mjs +0 -5
- package/dist/cli/notion-write-pretool.mjs +3 -1
- package/dist/cli/switchroom.js +1358 -1030
- package/dist/cli/ui/index.html +84 -12
- package/dist/host-control/main.js +53 -17
- package/dist/vault/approvals/kernel-server.js +4 -1
- package/dist/vault/broker/server.js +201 -56
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +1 -1
- package/profiles/_base/start.sh.hbs +54 -3
- package/skills/switchroom-architecture/telegram.md +8 -15
- package/skills/switchroom-cli/SKILL.md +4 -5
- package/skills/telegram-test-harness/SKILL.md +1 -1
- package/telegram-plugin/README.md +18 -29
- package/telegram-plugin/bridge/bridge.ts +1 -41
- package/telegram-plugin/bridge/tool-filter.ts +3 -4
- package/telegram-plugin/dist/bridge/bridge.js +8 -43
- package/telegram-plugin/dist/gateway/gateway.js +682 -773
- package/telegram-plugin/dist/server.js +8 -43
- package/telegram-plugin/gateway/busy-key-reaper.ts +113 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +11 -0
- package/telegram-plugin/gateway/escalation-bridge-gate.ts +46 -0
- package/telegram-plugin/gateway/gate-parity-probe.ts +102 -0
- package/telegram-plugin/gateway/gateway.ts +518 -624
- package/telegram-plugin/gateway/inbound-delivery-confirm.ts +89 -7
- package/telegram-plugin/gateway/inbound-spool.ts +108 -10
- package/telegram-plugin/gateway/model-command.ts +51 -3
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +26 -0
- package/telegram-plugin/gateway/represent-guard.ts +28 -11
- package/telegram-plugin/gateway/status-pin-store.ts +124 -45
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +19 -0
- package/telegram-plugin/history.ts +5 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +1 -2
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +9 -1
- package/telegram-plugin/registry/subagents-schema.ts +126 -1
- package/telegram-plugin/registry/turns-schema.ts +65 -1
- package/telegram-plugin/session-tail.ts +26 -4
- package/telegram-plugin/slot-banner-driver.ts +42 -2
- package/telegram-plugin/status-query-telemetry.ts +100 -0
- package/telegram-plugin/stream-reply-handler.ts +15 -16
- package/telegram-plugin/subagent-watcher.ts +182 -30
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +4 -10
- package/telegram-plugin/tests/busy-key-reaper.test.ts +191 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +11 -17
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +5 -26
- package/telegram-plugin/tests/escalation-bridge-gate.test.ts +38 -0
- package/telegram-plugin/tests/gate-parity-probe.test.ts +171 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +13 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +14 -11
- package/telegram-plugin/tests/inbound-delivery-confirm.test.ts +146 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +143 -0
- package/telegram-plugin/tests/model-command.test.ts +54 -1
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +5 -11
- package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +329 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +53 -0
- package/telegram-plugin/tests/progress-update-redact.test.ts +99 -0
- package/telegram-plugin/tests/registry-turns.test.ts +67 -0
- package/telegram-plugin/tests/represent-guard.test.ts +42 -6
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +1 -0
- package/telegram-plugin/tests/session-tail.test.ts +10 -1
- package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +246 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +0 -14
- package/telegram-plugin/tests/status-pin-store.test.ts +220 -5
- package/telegram-plugin/tests/status-query-telemetry.test.ts +115 -0
- package/telegram-plugin/tests/subagent-nested-dispatch.test.ts +209 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +37 -0
- package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +167 -0
- package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +46 -3
- package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +70 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +16 -0
- package/telegram-plugin/tests/tool-filter.test.ts +1 -3
- package/telegram-plugin/tests/tool-label-pretool.test.ts +1 -4
- package/telegram-plugin/tests/turn-flush-safety.test.ts +222 -1
- package/telegram-plugin/tests/worker-activity-feed.test.ts +202 -9
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +25 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +295 -0
- package/telegram-plugin/tool-activity-summary.ts +19 -0
- package/telegram-plugin/turn-flush-safety.ts +16 -1
- package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +8 -9
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +1 -1
- package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +1 -1
- package/telegram-plugin/worker-activity-feed.ts +75 -15
- package/vendor/hindsight-memory/CHANGELOG.md +24 -0
- package/vendor/hindsight-memory/README.md +5 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +31 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +41 -2
- package/vendor/hindsight-memory/scripts/lib/content.py +4 -1
- package/vendor/hindsight-memory/scripts/lib/daemon.py +11 -2
- package/vendor/hindsight-memory/scripts/recall.py +74 -1
- package/vendor/hindsight-memory/scripts/retain.py +8 -1
- package/vendor/hindsight-memory/scripts/tests/test_config_client_casts.py +111 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +85 -1
- package/vendor/hindsight-memory/scripts/tests/test_recall_tag_filters.py +107 -0
- package/vendor/hindsight-memory/settings.json +4 -0
- package/vendor/hindsight-memory/tests/test_client.py +130 -0
- package/vendor/hindsight-memory/tests/test_config.py +47 -0
- package/vendor/hindsight-memory/tests/test_content.py +18 -0
- package/vendor/hindsight-memory/tests/test_hooks.py +62 -0
- package/telegram-plugin/gateway/error-envelope-card.ts +0 -64
- package/telegram-plugin/gateway/resolve-calling-subagent.ts +0 -78
- package/telegram-plugin/silent-reply.ts +0 -58
- package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +0 -79
- package/telegram-plugin/tests/resolve-calling-subagent.test.ts +0 -269
- package/telegram-plugin/tests/silent-reply-guard.test.ts +0 -122
|
@@ -72,6 +72,36 @@ export function trackDelivery<M>(
|
|
|
72
72
|
q.pending.set(key, { key, inbound, messageId, lastAttemptAt: now })
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Extract every `message_id="…"` value carried in a rendered enqueue envelope.
|
|
77
|
+
*
|
|
78
|
+
* The `enqueue` session-event's message id (`enqueueMessageId`) is a SINGLE
|
|
79
|
+
* value re-parsed from the transcript by `parseChannelMeta`, which grabs the
|
|
80
|
+
* FIRST `message_id` it finds. When claude's composer MERGES several inbound
|
|
81
|
+
* envelopes into one turn (or reformats the wrapper), that first-parsed id can
|
|
82
|
+
* be a sibling's — not the tracked message's — even though the tracked
|
|
83
|
+
* message's envelope is right there in the same content. Strict equality then
|
|
84
|
+
* mis-concludes "never delivered" and the sweep re-delivers a turn that is
|
|
85
|
+
* already running (#2786). Scanning ALL ids in the raw content makes the ack
|
|
86
|
+
* tolerant of that reorder/merge: the tracked id is present iff its envelope
|
|
87
|
+
* was delivered, regardless of parse order.
|
|
88
|
+
*
|
|
89
|
+
* The regex is LEFT-ANCHORED on an attribute boundary (start-of-string or a
|
|
90
|
+
* whitespace/quote before the name) so it matches ONLY the real `message_id`
|
|
91
|
+
* attribute — never a same-suffix sibling like `target_message_id`,
|
|
92
|
+
* `reply_to_message_id`, `original_message_id`, or `card_message_id`. This is
|
|
93
|
+
* what makes the tolerant-path match safe on a never-drop code path: a
|
|
94
|
+
* substring collision on some other `*_message_id` attribute cannot false-ack
|
|
95
|
+
* (and thus silently drop) a real user message still waiting to land.
|
|
96
|
+
*/
|
|
97
|
+
export function extractEnqueueMessageIds(content: string): string[] {
|
|
98
|
+
const ids: string[] = []
|
|
99
|
+
const re = /(?:^|[\s"'])message_id="([^"]+)"/g
|
|
100
|
+
let m: RegExpExecArray | null
|
|
101
|
+
while ((m = re.exec(content)) != null) ids.push(m[1]!)
|
|
102
|
+
return ids
|
|
103
|
+
}
|
|
104
|
+
|
|
75
105
|
/**
|
|
76
106
|
* Ack a delivery — call from the `enqueue` session-event (claude started a
|
|
77
107
|
* turn). `enqueue` fires for EVERY turn start regardless of source (user
|
|
@@ -80,21 +110,36 @@ export function trackDelivery<M>(
|
|
|
80
110
|
* silently drop — a real user message still waiting under the same key. So
|
|
81
111
|
* ack ONLY when the enqueue's source message id matches the tracked one.
|
|
82
112
|
*
|
|
83
|
-
* Matching rule: if we recorded a messageId for the
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
113
|
+
* Matching rule (composer-tolerant, #2786): if we recorded a messageId for the
|
|
114
|
+
* pending entry, ack when EITHER
|
|
115
|
+
* - the single re-parsed `enqueueMessageId` equals it (the fast path), OR
|
|
116
|
+
* - the tracked id appears among the message ids carried in `enqueueContent`
|
|
117
|
+
* (the tolerant path — survives the composer merging/reordering envelopes
|
|
118
|
+
* so the first-parsed id belongs to a sibling, not the tracked message).
|
|
119
|
+
* Matching on the exact `message_id="…"` token — not a loose substring —
|
|
120
|
+
* preserves the cross-source false-ack guard: a synthetic-source turn (cron /
|
|
121
|
+
* resume / vault) never carries the real user message's id, so it still can't
|
|
122
|
+
* clear a user message that is genuinely still waiting to land. If we never
|
|
123
|
+
* recorded a messageId (legacy / defensive null), fall back to key-only ack.
|
|
124
|
+
* Returns true if an entry was cleared.
|
|
87
125
|
*/
|
|
88
126
|
export function ackDelivery<M>(
|
|
89
127
|
q: DeliveryQueue<M>,
|
|
90
128
|
key: string,
|
|
91
129
|
enqueueMessageId: string | null = null,
|
|
130
|
+
enqueueContent: string | null = null,
|
|
92
131
|
): boolean {
|
|
93
132
|
const entry = q.pending.get(key)
|
|
94
133
|
if (!entry) return false
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
134
|
+
if (entry.messageId != null) {
|
|
135
|
+
const matches =
|
|
136
|
+
entry.messageId === enqueueMessageId ||
|
|
137
|
+
(enqueueContent != null &&
|
|
138
|
+
extractEnqueueMessageIds(enqueueContent).includes(entry.messageId))
|
|
139
|
+
// A different message started this turn — don't ack ours (it may still be
|
|
140
|
+
// waiting to land; the sweep will re-deliver it if it stranded).
|
|
141
|
+
if (!matches) return false
|
|
142
|
+
}
|
|
98
143
|
q.pending.delete(key)
|
|
99
144
|
return true
|
|
100
145
|
}
|
|
@@ -119,6 +164,43 @@ export function sweep<M>(
|
|
|
119
164
|
return redeliver
|
|
120
165
|
}
|
|
121
166
|
|
|
167
|
+
/**
|
|
168
|
+
* #2787 — the set of chats/topics that currently hold a live permission or
|
|
169
|
+
* `ask_user` card, against which a swept entry is tested before re-delivery.
|
|
170
|
+
*
|
|
171
|
+
* - `keys` — full `chatKey(chatId, threadId)` values (topic known). A card
|
|
172
|
+
* routed to a specific forum topic suspends re-delivery ONLY for
|
|
173
|
+
* that topic; sibling topics in the same supergroup keep flowing.
|
|
174
|
+
* - `chats` — bare chatIds (topic unknown — e.g. a permission card fanned to
|
|
175
|
+
* the operator DMs records only the chatId). The conservative
|
|
176
|
+
* fallback is to suspend the whole chat; that chat is almost never
|
|
177
|
+
* a busy multi-topic supergroup, so the global stall is still gone.
|
|
178
|
+
*/
|
|
179
|
+
export interface SuspendedTargets {
|
|
180
|
+
readonly keys: ReadonlySet<string>
|
|
181
|
+
readonly chats: ReadonlySet<string>
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* #2787 Mechanism A — should re-delivery of a stranded inbound on `key` be
|
|
186
|
+
* suspended because a live permission / `ask_user` card is open for ITS OWN
|
|
187
|
+
* chat/topic?
|
|
188
|
+
*
|
|
189
|
+
* A pending card is a live interaction for its target: re-clearing the composer
|
|
190
|
+
* and re-sending there would clobber it. But the OLD gateway guard suspended the
|
|
191
|
+
* ENTIRE confirm sweep whenever ANY card was pending anywhere — so one card
|
|
192
|
+
* parked in a single topic (or the operator DM, a different chatId) froze
|
|
193
|
+
* re-delivery of every stranded inbound across EVERY topic until it resolved
|
|
194
|
+
* (the #1922 all-topics stall). Scoping the check to the card's own target keeps
|
|
195
|
+
* unrelated topics being re-delivered while the card sits open.
|
|
196
|
+
*/
|
|
197
|
+
export function isRedeliverySuspended(key: string, suspended: SuspendedTargets): boolean {
|
|
198
|
+
if (suspended.keys.has(key)) return true
|
|
199
|
+
const idx = key.indexOf(':')
|
|
200
|
+
const chatId = idx < 0 ? key : key.slice(0, idx)
|
|
201
|
+
return suspended.chats.has(chatId)
|
|
202
|
+
}
|
|
203
|
+
|
|
122
204
|
/** Forget a key without acking (e.g. the bridge went offline and the message
|
|
123
205
|
* was handed back to the offline buffer, which owns it now). */
|
|
124
206
|
export function forgetDelivery<M>(q: DeliveryQueue<M>, key: string): void {
|
|
@@ -159,6 +159,21 @@ export interface InboundSpoolOptions {
|
|
|
159
159
|
* `escalateAfterMs` here) or back-to-back attempts wouldn't coalesce.
|
|
160
160
|
* Default 30 min. */
|
|
161
161
|
escalateNoticeCooldownMs?: number
|
|
162
|
+
/**
|
|
163
|
+
* #2789 B: fired whenever spool durability changes state — an append
|
|
164
|
+
* fails (durability has silently degraded to in-memory-only, so a
|
|
165
|
+
* later crash loses those messages) or recovers. Latches: called once
|
|
166
|
+
* on the transition into degraded and once on the transition back to
|
|
167
|
+
* healthy, not on every append. Lets the gateway raise a health
|
|
168
|
+
* signal instead of quietly continuing as if the durable promise
|
|
169
|
+
* still held. Best-effort — a throw here never breaks delivery.
|
|
170
|
+
*/
|
|
171
|
+
onDegraded?: (info: {
|
|
172
|
+
degraded: boolean
|
|
173
|
+
consecutiveFailures: number
|
|
174
|
+
path: string
|
|
175
|
+
error?: string
|
|
176
|
+
}) => void
|
|
162
177
|
}
|
|
163
178
|
|
|
164
179
|
export interface ReplayEntry {
|
|
@@ -199,13 +214,25 @@ export interface InboundSpool {
|
|
|
199
214
|
* `escalateNoticeCooldownMs` — a burst of undeliverable inbounds (e.g.
|
|
200
215
|
* a synthetic re-created every 15 min while the agent is down, across
|
|
201
216
|
* restarts) yields ONE notice, not one per entry. The window is
|
|
202
|
-
* persisted, so it holds across a gateway restart.
|
|
203
|
-
* of entries dropped
|
|
217
|
+
* persisted, so it holds across a gateway restart. `droppedCount` is
|
|
218
|
+
* the real number of entries dropped for that entry's chat in THIS
|
|
219
|
+
* sweep, so the coalesced notice reports the true multi-message loss
|
|
220
|
+
* count instead of under-reporting it as one (#2789 C). Returns the
|
|
221
|
+
* total count of entries dropped. Safe to call on a timer. */
|
|
204
222
|
sweepEscalations: (
|
|
205
|
-
onEscalate: (
|
|
223
|
+
onEscalate: (
|
|
224
|
+
e: ReplayEntry,
|
|
225
|
+
opts: { postNotice: boolean; droppedCount: number },
|
|
226
|
+
) => void,
|
|
206
227
|
) => number
|
|
207
228
|
/** Test/observability: count of live (un-acked) ids. */
|
|
208
229
|
liveCount: () => number
|
|
230
|
+
/** #2789 B: true while spool appends are failing — durability has
|
|
231
|
+
* degraded to in-memory-only. A health signal the gateway can surface
|
|
232
|
+
* instead of quietly dropping the durability guarantee. */
|
|
233
|
+
isDegraded: () => boolean
|
|
234
|
+
/** #2789 B: consecutive append failures since the last success. */
|
|
235
|
+
appendFailureCount: () => number
|
|
209
236
|
}
|
|
210
237
|
|
|
211
238
|
export function createInboundSpool(opts: InboundSpoolOptions): InboundSpool {
|
|
@@ -227,6 +254,13 @@ export function createInboundSpool(opts: InboundSpoolOptions): InboundSpool {
|
|
|
227
254
|
// synthetic re-aged into the bound every 15 min across many restarts).
|
|
228
255
|
const escAttemptByChat = new Map<string, number>()
|
|
229
256
|
|
|
257
|
+
// #2789 B: append-durability health. `consecutiveAppendFailures`
|
|
258
|
+
// counts failed appends since the last success; `degraded` latches so
|
|
259
|
+
// the onDegraded callback fires exactly once per state transition
|
|
260
|
+
// (into degraded / back to healthy) rather than on every append.
|
|
261
|
+
let consecutiveAppendFailures = 0
|
|
262
|
+
let degraded = false
|
|
263
|
+
|
|
230
264
|
function parseLine(line: string): SpoolRecord | null {
|
|
231
265
|
const s = line.trim()
|
|
232
266
|
if (!s) return null
|
|
@@ -288,15 +322,52 @@ export function createInboundSpool(opts: InboundSpoolOptions): InboundSpool {
|
|
|
288
322
|
function appendRecord(rec: SpoolRecord): void {
|
|
289
323
|
try {
|
|
290
324
|
fs.appendFileSync(path, JSON.stringify(rec) + '\n')
|
|
325
|
+
// #2789 B: a successful append after a failure run means the spool
|
|
326
|
+
// is durable again — un-latch degraded state and surface recovery
|
|
327
|
+
// so the health signal clears.
|
|
328
|
+
if (consecutiveAppendFailures > 0) {
|
|
329
|
+
log(
|
|
330
|
+
`inbound-spool: append recovered path=${path} after ` +
|
|
331
|
+
`${consecutiveAppendFailures} failure(s) — durability restored\n`,
|
|
332
|
+
)
|
|
333
|
+
consecutiveAppendFailures = 0
|
|
334
|
+
if (degraded) {
|
|
335
|
+
degraded = false
|
|
336
|
+
try {
|
|
337
|
+
opts.onDegraded?.({ degraded: false, consecutiveFailures: 0, path })
|
|
338
|
+
} catch {
|
|
339
|
+
/* health signal is best-effort; never break delivery */
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
291
343
|
} catch (err) {
|
|
292
344
|
// Durability is best-effort relative to fs availability; a spool
|
|
293
|
-
// write failure must NOT break live delivery.
|
|
294
|
-
// persistently failing spool means
|
|
295
|
-
// semantics and
|
|
345
|
+
// write failure must NOT break live delivery. But it must NOT be
|
|
346
|
+
// SILENT either (#2789 B) — a persistently failing spool means
|
|
347
|
+
// we're back to in-memory-only semantics and a later crash loses
|
|
348
|
+
// the message. Log loudly on every failure AND raise a latched
|
|
349
|
+
// health signal on the transition into degraded so the operator /
|
|
350
|
+
// health surface knows the durability guarantee is gone.
|
|
351
|
+
consecutiveAppendFailures++
|
|
352
|
+
const message = (err as Error).message
|
|
296
353
|
log(
|
|
297
354
|
`inbound-spool: append FAILED path=${path} id=${rec.id} t=${rec.t}: ` +
|
|
298
|
-
`${
|
|
355
|
+
`${message} — durability degraded to in-memory ` +
|
|
356
|
+
`(consecutive failures=${consecutiveAppendFailures})\n`,
|
|
299
357
|
)
|
|
358
|
+
if (!degraded) {
|
|
359
|
+
degraded = true
|
|
360
|
+
try {
|
|
361
|
+
opts.onDegraded?.({
|
|
362
|
+
degraded: true,
|
|
363
|
+
consecutiveFailures: consecutiveAppendFailures,
|
|
364
|
+
path,
|
|
365
|
+
error: message,
|
|
366
|
+
})
|
|
367
|
+
} catch {
|
|
368
|
+
/* health signal is best-effort; never break delivery */
|
|
369
|
+
}
|
|
370
|
+
}
|
|
300
371
|
}
|
|
301
372
|
}
|
|
302
373
|
|
|
@@ -399,10 +470,25 @@ export function createInboundSpool(opts: InboundSpoolOptions): InboundSpool {
|
|
|
399
470
|
sweepEscalations(onEscalate) {
|
|
400
471
|
const tNow = now()
|
|
401
472
|
const cutoff = tNow - escalateAfterMs
|
|
473
|
+
// First pass (#2789 C): identify the entries to drop and count them
|
|
474
|
+
// per chat so the (coalesced) notice reports the REAL number of
|
|
475
|
+
// dropped messages instead of under-counting a multi-message drop
|
|
476
|
+
// as a single one. Iteration order is preserved so the tombstone /
|
|
477
|
+
// esc append order is unchanged.
|
|
478
|
+
const toDrop: {
|
|
479
|
+
id: string
|
|
480
|
+
e: { agent: string; msg: InboundMessage; firstAt: number }
|
|
481
|
+
}[] = []
|
|
482
|
+
const perChatCount = new Map<string, number>()
|
|
483
|
+
for (const [id, e] of live.entries()) {
|
|
484
|
+
if (e.firstAt > cutoff) continue
|
|
485
|
+
toDrop.push({ id, e })
|
|
486
|
+
const key = escChatKey(e.msg)
|
|
487
|
+
perChatCount.set(key, (perChatCount.get(key) ?? 0) + 1)
|
|
488
|
+
}
|
|
402
489
|
let dropped = 0
|
|
403
490
|
let posted = 0
|
|
404
|
-
for (const
|
|
405
|
-
if (e.firstAt > cutoff) continue
|
|
491
|
+
for (const { id, e } of toDrop) {
|
|
406
492
|
live.delete(id)
|
|
407
493
|
appendRecord({ t: 'ack', id }) // tombstone — promise retracted
|
|
408
494
|
// Coalesce the user-facing notice per chat on a SLIDING window:
|
|
@@ -420,7 +506,13 @@ export function createInboundSpool(opts: InboundSpoolOptions): InboundSpool {
|
|
|
420
506
|
typeof threadRaw === 'string' && threadRaw.length > 0 ? threadRaw : undefined
|
|
421
507
|
appendRecord({ t: 'esc', chat: e.msg.chatId, thread, at: tNow })
|
|
422
508
|
try {
|
|
423
|
-
|
|
509
|
+
// #2789 C: hand the caller the real per-chat drop count for
|
|
510
|
+
// THIS sweep so the notice it posts can say "N messages" rather
|
|
511
|
+
// than under-reporting a multi-message drop as a single one.
|
|
512
|
+
onEscalate(
|
|
513
|
+
{ agent: e.agent, msg: e.msg },
|
|
514
|
+
{ postNotice, droppedCount: perChatCount.get(key) ?? 1 },
|
|
515
|
+
)
|
|
424
516
|
} catch (err) {
|
|
425
517
|
log(`inbound-spool: onEscalate threw id=${id}: ${(err as Error).message}\n`)
|
|
426
518
|
}
|
|
@@ -441,5 +533,11 @@ export function createInboundSpool(opts: InboundSpoolOptions): InboundSpool {
|
|
|
441
533
|
liveCount() {
|
|
442
534
|
return live.size
|
|
443
535
|
},
|
|
536
|
+
isDegraded() {
|
|
537
|
+
return degraded
|
|
538
|
+
},
|
|
539
|
+
appendFailureCount() {
|
|
540
|
+
return consecutiveAppendFailures
|
|
541
|
+
},
|
|
444
542
|
}
|
|
445
543
|
}
|
|
@@ -227,11 +227,30 @@ export async function handleModelCommand(
|
|
|
227
227
|
}
|
|
228
228
|
|
|
229
229
|
if (result.outcome === 'ok') {
|
|
230
|
+
// claude's `/model <name>` switches the session SILENTLY — it does not
|
|
231
|
+
// print a confirmation line. So `result.output` on this path is almost
|
|
232
|
+
// always just whatever pane scrollback sat below the command echo (the
|
|
233
|
+
// agent's previous prose answer). `isTuiChromeLine` strips borders/glyphs
|
|
234
|
+
// but NOT ordinary prose, so blindly `preBlock`-ing `result.output` here
|
|
235
|
+
// dumped that unrelated scrollback back to the user as a code block
|
|
236
|
+
// (screenshot-confirmed on klanker, v0.16.47). Only relay output when it
|
|
237
|
+
// actually looks like a model-switch acknowledgement; otherwise suppress
|
|
238
|
+
// it and send a clean confirmation.
|
|
239
|
+
const confirmation = modelSwitchConfirmationLine(result.output)
|
|
240
|
+
if (confirmation) {
|
|
241
|
+
return {
|
|
242
|
+
text: [
|
|
243
|
+
`${verbHtml}`,
|
|
244
|
+
deps.preBlock(confirmation),
|
|
245
|
+
...(result.truncated ? ['_truncated_'] : []),
|
|
246
|
+
PERSIST_NOTE,
|
|
247
|
+
].join('\n'),
|
|
248
|
+
html: true,
|
|
249
|
+
}
|
|
250
|
+
}
|
|
230
251
|
return {
|
|
231
252
|
text: [
|
|
232
|
-
`${verbHtml}
|
|
233
|
-
deps.preBlock(result.output),
|
|
234
|
-
...(result.truncated ? ['_truncated_'] : []),
|
|
253
|
+
`${verbHtml} — switched (session).`,
|
|
235
254
|
PERSIST_NOTE,
|
|
236
255
|
].join('\n'),
|
|
237
256
|
html: true,
|
|
@@ -808,6 +827,35 @@ export function isSrToClaudeTransition(
|
|
|
808
827
|
return !!prevModel?.startsWith('sr-') && !nextModel.startsWith('sr-')
|
|
809
828
|
}
|
|
810
829
|
|
|
830
|
+
/**
|
|
831
|
+
* Return the single line of a pane capture that actually reads as claude's
|
|
832
|
+
* model-switch acknowledgement ("Set model to X…", "Switched to X", or
|
|
833
|
+
* "Kept model as X"), or null when no such line is present. Used by the
|
|
834
|
+
* direct `/model <name>` path to decide whether `result.output` carries a
|
|
835
|
+
* genuine confirmation worth relaying, versus mere scrollback that must NOT
|
|
836
|
+
* be echoed back to chat. Mirrors the line-scan already used by the picker
|
|
837
|
+
* alias/sr-* callback paths.
|
|
838
|
+
*/
|
|
839
|
+
export function modelSwitchConfirmationLine(output: string): string | null {
|
|
840
|
+
const line = output
|
|
841
|
+
.split('\n')
|
|
842
|
+
.map((l) => l.trim())
|
|
843
|
+
.find((l) => MODEL_SWITCH_CONFIRMATION_PREFIX.test(l))
|
|
844
|
+
return line && line.length > 0 ? line : null
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* claude's real model-switch confirmation always begins the line (optionally
|
|
849
|
+
* behind a status glyph like `⏺` + whitespace) with one of these exact
|
|
850
|
+
* phrasings. Anchoring to the line start keeps ordinary scrollback prose that
|
|
851
|
+
* merely *contains* words like "switched" or "set model" (e.g. "I switched the
|
|
852
|
+
* deploy to blue-green") from false-positiving as a confirmation worth
|
|
853
|
+
* relaying. Shared by `modelSwitchConfirmationLine` (does this line qualify?)
|
|
854
|
+
* and `sessionModelFromConfirmation` (pull the name out).
|
|
855
|
+
*/
|
|
856
|
+
const MODEL_SWITCH_CONFIRMATION_PREFIX =
|
|
857
|
+
/^\s*[⏺●•>-]?\s*(?:Set model to|Switched to|Kept model as)\b/i
|
|
858
|
+
|
|
811
859
|
/**
|
|
812
860
|
* Pull the model NAME out of claude's session-switch confirmation so it can
|
|
813
861
|
* be shown in `/status` as the live session model. claude phrases it as
|
|
@@ -66,6 +66,19 @@ export interface PendingInboundBufferOptions {
|
|
|
66
66
|
* than the old silent in-memory drop).
|
|
67
67
|
*/
|
|
68
68
|
spool?: InboundSpool
|
|
69
|
+
/**
|
|
70
|
+
* Called when the in-memory cap forces an eviction of the OLDEST
|
|
71
|
+
* entry (#2789 defect A). The evicted message is NOT lost — `push`
|
|
72
|
+
* still records it durably in the spool (boot-replayed / escalated) —
|
|
73
|
+
* but within a live session it will not be re-delivered until boot or
|
|
74
|
+
* escalation, so the eviction must not be SILENT the way it was
|
|
75
|
+
* before. The caller wires this to a coalesced "N messages deferred"
|
|
76
|
+
* user-facing notice tied to the spool, turning a silent in-session
|
|
77
|
+
* drop into a visible deferral (chat-is-the-single-source-of-truth:
|
|
78
|
+
* surface the loss window, don't hide it). Best-effort: a throw here
|
|
79
|
+
* never breaks the push hot path.
|
|
80
|
+
*/
|
|
81
|
+
onEvict?: (agent: string, evicted: InboundMessage) => void
|
|
69
82
|
}
|
|
70
83
|
|
|
71
84
|
/**
|
|
@@ -304,6 +317,19 @@ export function createPendingInboundBuffer(
|
|
|
304
317
|
`pending-inbound-buffer: agent=${agent} cap=${cap} reached — ` +
|
|
305
318
|
`dropped oldest entry source=${dropped?.meta?.source ?? '-'} ts=${dropped?.ts ?? '-'}\n`,
|
|
306
319
|
)
|
|
320
|
+
// #2789 A: the cap eviction is no longer a SILENT in-session
|
|
321
|
+
// drop. `dropped` still lives in the durable spool (it was
|
|
322
|
+
// spool.put on its own push), so it survives to boot-replay /
|
|
323
|
+
// escalation — but it won't be re-delivered THIS session. Hand
|
|
324
|
+
// it to the caller so a coalesced "N messages deferred" notice
|
|
325
|
+
// can be surfaced. Best-effort: never let the notice break push.
|
|
326
|
+
if (dropped != null && opts.onEvict != null) {
|
|
327
|
+
try {
|
|
328
|
+
opts.onEvict(agent, dropped)
|
|
329
|
+
} catch {
|
|
330
|
+
/* user-facing notice is best-effort; never break the hot path */
|
|
331
|
+
}
|
|
332
|
+
}
|
|
307
333
|
}
|
|
308
334
|
q.push(msg)
|
|
309
335
|
// Durable record FIRST-class to the in-memory queue: spool BEFORE
|
|
@@ -14,14 +14,22 @@
|
|
|
14
14
|
* no Telegram, no SQLite; the gateway injects `hasOutboundDeliveredSince` as a
|
|
15
15
|
* predicate. The single load-bearing subtlety lives here in one testable place:
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* the
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
17
|
+
* For the SECOND-and-later represent the cutoff is `lastRepresentedAt` (the
|
|
18
|
+
* time of the PREVIOUS represent), NOT `openedAt` — exactly where a reply that
|
|
19
|
+
* landed BETWEEN fires must suppress the re-ask, while a reply that predates
|
|
20
|
+
* the last represent (e.g. the original plain-text answer) does not count,
|
|
21
|
+
* because it is not evidence the most recent represent was answered.
|
|
22
|
+
*
|
|
23
|
+
* For the FIRST represent (`lastRepresentedAt` undefined) the cutoff is
|
|
24
|
+
* `openedAt` (#2788 Gap B). Previously the first represent was an unconditional
|
|
25
|
+
* no-op, leaving a narrow window: if a genuine reply was already delivered
|
|
26
|
+
* since the obligation was RAISED but its routing didn't resolve back to the
|
|
27
|
+
* origin (so the ledger's normal close path missed it), the first represent
|
|
28
|
+
* emitted a false "you never answered". Deduping against outbound history from
|
|
29
|
+
* `openedAt` closes that window WITHOUT breaking the genuine "agent wrote a
|
|
30
|
+
* plain-text answer and never called the reply tool" case: that case records NO
|
|
31
|
+
* outbound row, so `hasOutboundDeliveredSince` reports false and the single
|
|
32
|
+
* re-ask still fires.
|
|
25
33
|
*/
|
|
26
34
|
|
|
27
35
|
/** The obligation fields the represent guard inspects. */
|
|
@@ -29,6 +37,8 @@ export interface RepresentGuardObligation {
|
|
|
29
37
|
readonly originTurnId: string
|
|
30
38
|
readonly chatId: string
|
|
31
39
|
readonly threadId?: number
|
|
40
|
+
/** Wall-clock ms this obligation was RAISED — the FIRST-represent cutoff (#2788). */
|
|
41
|
+
readonly openedAt?: number
|
|
32
42
|
/** Wall-clock ms this obligation was most recently re-presented, if ever. */
|
|
33
43
|
readonly lastRepresentedAt?: number
|
|
34
44
|
}
|
|
@@ -65,8 +75,15 @@ export function shouldSuppressRepresent(
|
|
|
65
75
|
deps: RepresentGuardDeps,
|
|
66
76
|
): boolean {
|
|
67
77
|
if (!deps.historyEnabled) return false
|
|
68
|
-
// First represent
|
|
69
|
-
//
|
|
70
|
-
|
|
78
|
+
// First represent (#2788 Gap B): dedup against outbound history from `openedAt`.
|
|
79
|
+
// A genuine reply already delivered since the obligation was raised means the
|
|
80
|
+
// user WAS answered — suppress the false "you never answered". The genuine
|
|
81
|
+
// plain-text-no-reply case records no outbound row, so the predicate reports
|
|
82
|
+
// false there and the single re-ask still fires. If `openedAt` is unknown we
|
|
83
|
+
// cannot dedup safely, so fall back to the prior no-op (never suppress).
|
|
84
|
+
if (o.lastRepresentedAt == null) {
|
|
85
|
+
if (o.openedAt == null) return false
|
|
86
|
+
return deps.hasOutboundDeliveredSince(o.chatId, o.openedAt, o.threadId)
|
|
87
|
+
}
|
|
71
88
|
return deps.hasOutboundDeliveredSince(o.chatId, o.lastRepresentedAt, o.threadId)
|
|
72
89
|
}
|