switchroom 0.13.19 → 0.13.21
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/cli/switchroom.js +2 -2
- package/package.json +1 -1
- package/profiles/_shared/telegram-style.md.hbs +3 -3
- package/telegram-plugin/dist/gateway/gateway.js +201 -31
- package/telegram-plugin/gateway/disconnect-flush.ts +37 -0
- package/telegram-plugin/gateway/gateway.ts +138 -8
- package/telegram-plugin/gateway/inbound-delivery-gate.ts +37 -4
- package/telegram-plugin/handoff-continuity.ts +8 -2
- package/telegram-plugin/recent-outbound-dedup.ts +51 -5
- package/telegram-plugin/runtime-metrics.ts +22 -0
- package/telegram-plugin/subagent-watcher.ts +25 -3
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +114 -0
- package/telegram-plugin/tests/handoff-continuity.test.ts +15 -2
- package/telegram-plugin/tests/inbound-delivery-gate.test.ts +77 -4
- package/telegram-plugin/tests/recent-outbound-dedup.test.ts +72 -0
- package/telegram-plugin/tests/subagent-watcher-enoent-deregister.test.ts +152 -0
- package/telegram-plugin/tests/text-voice-scrub.test.ts +174 -0
- package/telegram-plugin/text-voice-scrub.ts +199 -0
- package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +72 -45
|
@@ -154,6 +154,7 @@ const SILENT_END_FALLBACK_TEXT =
|
|
|
154
154
|
'⚠️ The agent finished working but didn’t send a reply — your last ' +
|
|
155
155
|
'message may not have been answered. Please try asking again.'
|
|
156
156
|
import { markdownToHtml, splitHtmlChunks, repairEscapedWhitespace, telegramHtmlToPlainText } from '../format.js'
|
|
157
|
+
import { scrubVoice } from '../text-voice-scrub.js'
|
|
157
158
|
import {
|
|
158
159
|
validateInlineKeyboard,
|
|
159
160
|
type AnyButton,
|
|
@@ -3083,6 +3084,30 @@ silencePoke.startTimer({
|
|
|
3083
3084
|
emitRuntimeMetric(event)
|
|
3084
3085
|
},
|
|
3085
3086
|
onFrameworkFallback: async (ctx) => {
|
|
3087
|
+
// Late-fire short-circuit (2026-05-23 audit finding). The fallback
|
|
3088
|
+
// can race a clean turn-end: the model's actual reply lands inside
|
|
3089
|
+
// the silence window's final ~50ms, the canonical turn-end path
|
|
3090
|
+
// clears `activeTurnStartedAt` and nulls `currentTurn`, and then
|
|
3091
|
+
// this handler fires anyway. Without this check we emit a noisy
|
|
3092
|
+
// "still working…" ping to the user (right after they got their
|
|
3093
|
+
// real reply) AND a misleading "ended wedged turn ... currentTurn_
|
|
3094
|
+
// nulled=false drained_buffered=0/0" log line. The 7-day audit
|
|
3095
|
+
// showed this race accounts for ~90% of all framework_fallback log
|
|
3096
|
+
// events (124 of 138 `currentTurn_nulled=false` cases). Distinct
|
|
3097
|
+
// log line so observability still tracks the fact that the silence
|
|
3098
|
+
// crossed threshold; the wedge counter is no longer polluted.
|
|
3099
|
+
if (activeTurnStartedAt.get(ctx.key) == null && currentTurn == null) {
|
|
3100
|
+
process.stderr.write(
|
|
3101
|
+
`telegram gateway: silence-poke framework-fallback late-fire skipped — ` +
|
|
3102
|
+
`turn ended cleanly during silence window ` +
|
|
3103
|
+
`chat=${ctx.chatId} thread=${ctx.threadId ?? '-'} silence_ms=${ctx.silenceMs}\n`,
|
|
3104
|
+
)
|
|
3105
|
+
// Tell silence-poke this chat-thread is finished so the next
|
|
3106
|
+
// arming doesn't carry stale state.
|
|
3107
|
+
silencePoke.endTurn(ctx.key)
|
|
3108
|
+
return
|
|
3109
|
+
}
|
|
3110
|
+
|
|
3086
3111
|
// Deterministic in-flight update status (klanker incident). If this
|
|
3087
3112
|
// gateway dispatched an update_apply that's still running, the
|
|
3088
3113
|
// recurring framework fallback carries hostd's REAL phase + elapsed
|
|
@@ -3578,6 +3603,18 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
3578
3603
|
// scripts/check-plugin-references.mjs (TS2722).
|
|
3579
3604
|
progressDriver?.dispose?.({ preservePending: true })
|
|
3580
3605
|
},
|
|
3606
|
+
// When dangling activeTurnStartedAt keys were swept (setDone raced
|
|
3607
|
+
// disconnect), the module-scope `currentTurn` may also point at the
|
|
3608
|
+
// dead bridge's turn. Null it so the next inbound starts a fresh
|
|
3609
|
+
// turn instead of inheriting a ghost.
|
|
3610
|
+
onDanglingTurnsSwept: () => {
|
|
3611
|
+
if (currentTurn != null) {
|
|
3612
|
+
process.stderr.write(
|
|
3613
|
+
`telegram gateway: disconnect-flush nulled currentTurn (bridge died with turn in flight)\n`,
|
|
3614
|
+
)
|
|
3615
|
+
currentTurn = null
|
|
3616
|
+
}
|
|
3617
|
+
},
|
|
3581
3618
|
log: (msg) => process.stderr.write(`${msg}\n`),
|
|
3582
3619
|
})
|
|
3583
3620
|
},
|
|
@@ -4197,6 +4234,26 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
4197
4234
|
const rawText = args.text as string | undefined
|
|
4198
4235
|
if (rawText == null || rawText === '') throw new Error('reply: text is required and cannot be empty')
|
|
4199
4236
|
let text = repairEscapedWhitespace(rawText)
|
|
4237
|
+
// Voice scrub (#1683): replace em / en dashes with commas / periods.
|
|
4238
|
+
// Runs BEFORE outboundDedup so retries see the scrubbed key, and
|
|
4239
|
+
// BEFORE markdownToHtml so code-block content is correctly parked
|
|
4240
|
+
// by the scrubber's own placeholder pass (otherwise the html
|
|
4241
|
+
// converter would have already escaped/parked code, and the scrub
|
|
4242
|
+
// would see only the parked placeholders). Kill switch:
|
|
4243
|
+
// `SWITCHROOM_DISABLE_VOICE_SCRUB=1`.
|
|
4244
|
+
{
|
|
4245
|
+
const scrub = scrubVoice(text)
|
|
4246
|
+
if (scrub.replaced > 0) {
|
|
4247
|
+
text = scrub.scrubbed
|
|
4248
|
+
emitRuntimeMetric({
|
|
4249
|
+
kind: 'voice_scrub_applied',
|
|
4250
|
+
chatKey: statusKey(chat_id, args.message_thread_id != null
|
|
4251
|
+
? Number(args.message_thread_id) : undefined),
|
|
4252
|
+
replaced: scrub.replaced,
|
|
4253
|
+
site: 'reply',
|
|
4254
|
+
})
|
|
4255
|
+
}
|
|
4256
|
+
}
|
|
4200
4257
|
process.stderr.write(`telegram channel: reply: invoked chatId=${chat_id} charCount=${text.length} preview=${JSON.stringify(text.slice(0, 80))}\n`)
|
|
4201
4258
|
|
|
4202
4259
|
// #546 dedup check: was this content just sent via turn-flush or
|
|
@@ -4206,7 +4263,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
4206
4263
|
// late-replies with different content sail through.
|
|
4207
4264
|
{
|
|
4208
4265
|
const replyThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
4209
|
-
const dup = outboundDedup.check(chat_id, replyThreadId, text, Date.now())
|
|
4266
|
+
const dup = outboundDedup.check(chat_id, replyThreadId, text, Date.now(), currentTurn?.registryKey ?? null)
|
|
4210
4267
|
if (dup != null) {
|
|
4211
4268
|
process.stderr.write(
|
|
4212
4269
|
`telegram gateway: reply: deduped (#546) chatId=${chat_id} ` +
|
|
@@ -4540,6 +4597,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
4540
4597
|
threadId,
|
|
4541
4598
|
decision.mergedText,
|
|
4542
4599
|
Date.now(),
|
|
4600
|
+
turn?.registryKey ?? null,
|
|
4543
4601
|
)
|
|
4544
4602
|
|
|
4545
4603
|
silentAnchorEditDone = true
|
|
@@ -4864,7 +4922,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
4864
4922
|
// calls with this same content within DEFAULT_DEDUP_TTL_MS will
|
|
4865
4923
|
// be suppressed.
|
|
4866
4924
|
if (sentIds.length > 0) {
|
|
4867
|
-
outboundDedup.record(chat_id, threadId, text, Date.now())
|
|
4925
|
+
outboundDedup.record(chat_id, threadId, text, Date.now(), currentTurn?.registryKey ?? null)
|
|
4868
4926
|
}
|
|
4869
4927
|
return { content: [{ type: 'text', text: result }] }
|
|
4870
4928
|
}
|
|
@@ -4875,6 +4933,31 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
4875
4933
|
if (!args.chat_id) throw new Error('stream_reply: chat_id is required')
|
|
4876
4934
|
if (args.text == null || args.text === '') throw new Error('stream_reply: text is required and cannot be empty')
|
|
4877
4935
|
|
|
4936
|
+
// Voice scrub (PR #1683 follow-up). Modern Claude on the fleet
|
|
4937
|
+
// uses the answer-stream / draft-stream path for multi-paragraph
|
|
4938
|
+
// replies — the model emits via stream_reply and the original
|
|
4939
|
+
// PR #1683 scrub site (executeReply) never sees the text. klanker's
|
|
4940
|
+
// 2026-05-24 log showed model output with em-dashes routed via
|
|
4941
|
+
// stream_reply done=true, materializing as sendMessage with no
|
|
4942
|
+
// scrub. Mirror the executeReply pattern here: scrub BEFORE the
|
|
4943
|
+
// outbound-dedup check (so retries see the scrubbed key) and
|
|
4944
|
+
// mutate args.text so all downstream consumers (the stream-
|
|
4945
|
+
// controller, dedup record, history record) see the scrubbed
|
|
4946
|
+
// version. Kill switch: SWITCHROOM_DISABLE_VOICE_SCRUB.
|
|
4947
|
+
{
|
|
4948
|
+
const scrub = scrubVoice(args.text as string)
|
|
4949
|
+
if (scrub.replaced > 0) {
|
|
4950
|
+
args.text = scrub.scrubbed
|
|
4951
|
+
emitRuntimeMetric({
|
|
4952
|
+
kind: 'voice_scrub_applied',
|
|
4953
|
+
chatKey: statusKey(args.chat_id as string, args.message_thread_id != null
|
|
4954
|
+
? Number(args.message_thread_id) : undefined),
|
|
4955
|
+
replaced: scrub.replaced,
|
|
4956
|
+
site: 'stream_reply',
|
|
4957
|
+
})
|
|
4958
|
+
}
|
|
4959
|
+
}
|
|
4960
|
+
|
|
4878
4961
|
// #546 dedup check: stream_reply done=true is the most-common
|
|
4879
4962
|
// retry shape — claude-code re-emits the final-text call when
|
|
4880
4963
|
// the previous bridge missed the ack. If turn-flush already sent
|
|
@@ -4885,7 +4968,7 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
4885
4968
|
const sChatId = args.chat_id as string
|
|
4886
4969
|
const sThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
4887
4970
|
const sText = args.text as string
|
|
4888
|
-
const dup = outboundDedup.check(sChatId, sThreadId, sText, Date.now())
|
|
4971
|
+
const dup = outboundDedup.check(sChatId, sThreadId, sText, Date.now(), currentTurn?.registryKey ?? null)
|
|
4889
4972
|
if (dup != null) {
|
|
4890
4973
|
process.stderr.write(
|
|
4891
4974
|
`telegram gateway: stream_reply: deduped (#546) chatId=${sChatId} ` +
|
|
@@ -5049,7 +5132,7 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
5049
5132
|
if (args.done === true && result.messageId != null) {
|
|
5050
5133
|
const sChatId = args.chat_id as string
|
|
5051
5134
|
const sThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
5052
|
-
outboundDedup.record(sChatId, sThreadId, args.text as string, Date.now())
|
|
5135
|
+
outboundDedup.record(sChatId, sThreadId, args.text as string, Date.now(), currentTurn?.registryKey ?? null)
|
|
5053
5136
|
// #1445 cross-turn pending-async ambient. The terminal stream_reply
|
|
5054
5137
|
// (done=true) is the user-visible anchor for any cross-turn wait
|
|
5055
5138
|
// that follows. Capture it so if this turn ends with a pending
|
|
@@ -5842,7 +5925,23 @@ async function executeEditMessage(args: Record<string, unknown>): Promise<unknow
|
|
|
5842
5925
|
const editAccess = loadAccess()
|
|
5843
5926
|
const editConfigMode = editAccess.parseMode ?? 'html'
|
|
5844
5927
|
const editFormat = (args.format as string | undefined) ?? editConfigMode
|
|
5845
|
-
|
|
5928
|
+
let editRawText = repairEscapedWhitespace(args.text as string)
|
|
5929
|
+
// Voice scrub (#1683): same em-dash scrub as the reply path. Edits
|
|
5930
|
+
// are how silent-anchor and progress-update mutate already-sent
|
|
5931
|
+
// bubbles, so without this an edit can re-introduce dashes the
|
|
5932
|
+
// original send had scrubbed out.
|
|
5933
|
+
{
|
|
5934
|
+
const scrub = scrubVoice(editRawText)
|
|
5935
|
+
if (scrub.replaced > 0) {
|
|
5936
|
+
editRawText = scrub.scrubbed
|
|
5937
|
+
emitRuntimeMetric({
|
|
5938
|
+
kind: 'voice_scrub_applied',
|
|
5939
|
+
chatKey: statusKey(args.chat_id as string, undefined),
|
|
5940
|
+
replaced: scrub.replaced,
|
|
5941
|
+
site: 'edit_message',
|
|
5942
|
+
})
|
|
5943
|
+
}
|
|
5944
|
+
}
|
|
5846
5945
|
let editParseMode: 'HTML' | 'MarkdownV2' | undefined
|
|
5847
5946
|
let editText: string
|
|
5848
5947
|
if (editFormat === 'html') {
|
|
@@ -6345,10 +6444,10 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6345
6444
|
// threadId come from the captured `turn` snapshot, stable for
|
|
6346
6445
|
// the lifetime of the stream.
|
|
6347
6446
|
checkDedup: (text: string) => {
|
|
6348
|
-
return outboundDedup.check(turn.sessionChatId, turn.sessionThreadId, text, Date.now()) != null
|
|
6447
|
+
return outboundDedup.check(turn.sessionChatId, turn.sessionThreadId, text, Date.now(), turn.registryKey ?? null) != null
|
|
6349
6448
|
},
|
|
6350
6449
|
recordDedup: (text: string) => {
|
|
6351
|
-
outboundDedup.record(turn.sessionChatId, turn.sessionThreadId, text, Date.now())
|
|
6450
|
+
outboundDedup.record(turn.sessionChatId, turn.sessionThreadId, text, Date.now(), turn.registryKey ?? null)
|
|
6352
6451
|
},
|
|
6353
6452
|
// #648 — write answer-stream materializations into the SQLite
|
|
6354
6453
|
// history buffer so get_recent_messages can surface them. Guard
|
|
@@ -6509,6 +6608,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6509
6608
|
turn.sessionThreadId,
|
|
6510
6609
|
streamedFinalText,
|
|
6511
6610
|
Date.now(),
|
|
6611
|
+
turn.registryKey ?? null,
|
|
6512
6612
|
)
|
|
6513
6613
|
} catch { /* best-effort */ }
|
|
6514
6614
|
if (HISTORY_ENABLED) {
|
|
@@ -6678,11 +6778,31 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6678
6778
|
}
|
|
6679
6779
|
|
|
6680
6780
|
if (flushDecision.kind === 'flush') {
|
|
6681
|
-
|
|
6781
|
+
let capturedText = flushDecision.text
|
|
6682
6782
|
const backstopChatId = chatId
|
|
6683
6783
|
const backstopThreadId = threadId
|
|
6684
6784
|
const backstopCtrl = ctrl
|
|
6685
6785
|
|
|
6786
|
+
// Voice scrub (PR #1683 follow-up). Turn-flush is the path
|
|
6787
|
+
// that fires when the model emits raw transcript text WITHOUT
|
|
6788
|
+
// calling reply / stream_reply. That captured text bypasses
|
|
6789
|
+
// PR #1683's executeReply scrub site entirely and is delivered
|
|
6790
|
+
// via sendMessage / editMessageText directly. Scrub the
|
|
6791
|
+
// capturedText before markdownToHtml so em-dashes never reach
|
|
6792
|
+
// the wire. Kill switch: SWITCHROOM_DISABLE_VOICE_SCRUB.
|
|
6793
|
+
{
|
|
6794
|
+
const scrub = scrubVoice(capturedText)
|
|
6795
|
+
if (scrub.replaced > 0) {
|
|
6796
|
+
capturedText = scrub.scrubbed
|
|
6797
|
+
emitRuntimeMetric({
|
|
6798
|
+
kind: 'voice_scrub_applied',
|
|
6799
|
+
chatKey: statusKey(backstopChatId, backstopThreadId),
|
|
6800
|
+
replaced: scrub.replaced,
|
|
6801
|
+
site: 'turn_flush',
|
|
6802
|
+
})
|
|
6803
|
+
}
|
|
6804
|
+
}
|
|
6805
|
+
|
|
6686
6806
|
// #1664 — turn-flush only fires when !replyCalled (decideTurnFlush
|
|
6687
6807
|
// returns 'reply-called' otherwise). It legitimately delivers the
|
|
6688
6808
|
// model's terminal text as the answer, so the turn IS answered.
|
|
@@ -6874,6 +6994,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6874
6994
|
backstopThreadId,
|
|
6875
6995
|
capturedText,
|
|
6876
6996
|
Date.now(),
|
|
6997
|
+
currentTurn?.registryKey ?? null,
|
|
6877
6998
|
)
|
|
6878
6999
|
if (backstopCtrl) backstopCtrl.setDone()
|
|
6879
7000
|
// Unpin the card. completeTurn cleans up pinMgr's per-turn
|
|
@@ -8418,6 +8539,15 @@ async function handleInbound(
|
|
|
8418
8539
|
decideInboundDelivery({
|
|
8419
8540
|
turnInFlight: turnInFlightAtReceipt,
|
|
8420
8541
|
isSteering,
|
|
8542
|
+
// Interrupt-marker carve-out (2026-05-24): the `!`-prefixed body
|
|
8543
|
+
// must bypass the "buffer-until-turn-complete" gate because the
|
|
8544
|
+
// SIGINT'd turn often doesn't emit turn_complete, leaving the
|
|
8545
|
+
// body stranded in pendingInboundBuffer indefinitely. The
|
|
8546
|
+
// `interrupt` const is computed at the start of handleInbound
|
|
8547
|
+
// (line ~7606) and remains in scope here. When the user fires
|
|
8548
|
+
// `!`-with-body, this delivers the body as a fresh inbound to
|
|
8549
|
+
// the freshly-killed bridge.
|
|
8550
|
+
isInterrupt: interrupt.isInterrupt,
|
|
8421
8551
|
}) === 'buffer-until-idle'
|
|
8422
8552
|
) {
|
|
8423
8553
|
pendingInboundBuffer.push(selfAgent, inboundMsg)
|
|
@@ -53,6 +53,27 @@
|
|
|
53
53
|
* mid-turn — that is the whole point of the steering feature (redirect
|
|
54
54
|
* the agent while it works). Steering messages keep immediate delivery.
|
|
55
55
|
* The wedge only ever affected the queued-mid-turn default path.
|
|
56
|
+
*
|
|
57
|
+
* ## Interrupt-marker is also exempt (2026-05-24 fix)
|
|
58
|
+
*
|
|
59
|
+
* An inbound prefixed with `!` invokes the interrupt path
|
|
60
|
+
* (`gateway.ts:handleInbound` parse + `tmux send-keys C-c` to the
|
|
61
|
+
* bridge). The SIGINT kills the in-flight turn at the SDK level — but
|
|
62
|
+
* the killed turn does NOT always emit `turn_complete`. Without that
|
|
63
|
+
* event, the turn-complete buffer-flush never fires, and the
|
|
64
|
+
* post-SIGINT inbound body (the `!` replacement instruction) rots in
|
|
65
|
+
* `pendingInboundBuffer` indefinitely.
|
|
66
|
+
*
|
|
67
|
+
* 2026-05-24 live UAT trace: user fires `! actually reply hello`,
|
|
68
|
+
* SIGINT delivered, killed turn never emits `turn_complete`, buffer
|
|
69
|
+
* stays full, user sees no response. The Phase-3 audit had this UAT
|
|
70
|
+
* `describe.skip`'d as "real interrupt-marker wedge or prompt-shape
|
|
71
|
+
* issue" — confirmed real.
|
|
72
|
+
*
|
|
73
|
+
* Resolution: bypass the gate for interrupt inbounds. The interrupt
|
|
74
|
+
* carve-out is a peer of `isSteering` — both are "intentional
|
|
75
|
+
* mid-turn delivery" cases. Caller passes the interrupt flag from the
|
|
76
|
+
* inbound parse; the gate returns `'deliver'` immediately.
|
|
56
77
|
*/
|
|
57
78
|
|
|
58
79
|
export interface InboundDeliveryGateInput {
|
|
@@ -63,6 +84,14 @@ export interface InboundDeliveryGateInput {
|
|
|
63
84
|
/** This inbound carried an explicit `/steer` (`/s`) prefix and is an
|
|
64
85
|
* intentional mid-turn redirect. */
|
|
65
86
|
isSteering: boolean
|
|
87
|
+
/** This inbound was parsed by `parseInterruptMarker` as a `!`-prefixed
|
|
88
|
+
* interrupt request. The gateway has already (or is about to) deliver
|
|
89
|
+
* the SIGINT to claude via tmux send-keys; the body of the message
|
|
90
|
+
* (post-`!`) is the user's replacement instruction. Without this
|
|
91
|
+
* carve-out, the body rots in pendingInboundBuffer because the
|
|
92
|
+
* SIGINT'd turn doesn't reliably emit turn_complete to drain the
|
|
93
|
+
* buffer. Optional + defaults false for backward compat. */
|
|
94
|
+
isInterrupt?: boolean
|
|
66
95
|
}
|
|
67
96
|
|
|
68
97
|
export type InboundDeliveryDecision =
|
|
@@ -73,13 +102,17 @@ export type InboundDeliveryDecision =
|
|
|
73
102
|
| 'buffer-until-idle'
|
|
74
103
|
|
|
75
104
|
/**
|
|
76
|
-
* Pure.
|
|
77
|
-
*
|
|
78
|
-
*
|
|
105
|
+
* Pure. Defers delivery ONLY when a turn is in flight AND this inbound
|
|
106
|
+
* is neither steering nor an interrupt. Idle → deliver. Steering → deliver
|
|
107
|
+
* (intentional mid-turn redirect). Interrupt → deliver (the `!`
|
|
108
|
+
* carve-out — see header doc; the killed turn may never drain the
|
|
109
|
+
* buffer, so we must not buffer in the first place).
|
|
79
110
|
*/
|
|
80
111
|
export function decideInboundDelivery(
|
|
81
112
|
input: InboundDeliveryGateInput,
|
|
82
113
|
): InboundDeliveryDecision {
|
|
83
|
-
if (input.
|
|
114
|
+
if (input.isSteering) return 'deliver'
|
|
115
|
+
if (input.isInterrupt === true) return 'deliver'
|
|
116
|
+
if (input.turnInFlight) return 'buffer-until-idle'
|
|
84
117
|
return 'deliver'
|
|
85
118
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* (written by the summarizer Stop hook). On the FIRST assistant reply
|
|
6
6
|
* of the new session the plugin prepends a subtle one-liner:
|
|
7
7
|
*
|
|
8
|
-
* ↩️ Picked up where we left off
|
|
8
|
+
* ↩️ Picked up where we left off, <topic>
|
|
9
9
|
*
|
|
10
10
|
* The sidecar is consumed (read + deleted) so the line only fires once.
|
|
11
11
|
* All helpers here are filesystem-only or env-only — no Telegram side
|
|
@@ -175,7 +175,13 @@ export function formatHandoffLine(
|
|
|
175
175
|
topic: string,
|
|
176
176
|
format: HandoffFormat,
|
|
177
177
|
): string {
|
|
178
|
-
|
|
178
|
+
// Comma instead of em-dash: the framework-emitted prefix is
|
|
179
|
+
// concatenated AFTER scrubVoice runs on the model body (gateway.ts
|
|
180
|
+
// executeReply), so any em-dash here bypasses the v0.13.20 voice
|
|
181
|
+
// scrub. Replacing at the template source is one mechanical change
|
|
182
|
+
// that closes the dominant residual em-dash leak (16 of 17 dashed
|
|
183
|
+
// messages on test-harness were this template per 2026-05-24 audit).
|
|
184
|
+
const prefix = "↩️ Picked up where we left off, ";
|
|
179
185
|
if (format === "html") {
|
|
180
186
|
return `<i>${prefix}${escapeHtml(topic)}</i>\n\n`;
|
|
181
187
|
}
|
|
@@ -57,6 +57,16 @@ interface DedupEntry {
|
|
|
57
57
|
/** First 80 chars of the original (un-normalized) text — for
|
|
58
58
|
* operator-facing log lines that show what got deduped. */
|
|
59
59
|
preview: string
|
|
60
|
+
/** The `currentTurn.registryKey` at record time, or `null` if the
|
|
61
|
+
* recording site had no turn context. Threaded through so check()
|
|
62
|
+
* can distinguish within-turn retries (#546 bug class — keep
|
|
63
|
+
* protecting) from cross-turn coincidences (2026-05-23 audit found
|
|
64
|
+
* identical mid-turn + final replies across two turns ~30s apart
|
|
65
|
+
* silently swallowing the second turn's answer; the user gets
|
|
66
|
+
* no response to their second question). Null on either side
|
|
67
|
+
* matches as before, preserving the boot-time / edge-case behaviour
|
|
68
|
+
* the original tests pin. */
|
|
69
|
+
turnKey: string | null
|
|
60
70
|
}
|
|
61
71
|
|
|
62
72
|
/**
|
|
@@ -75,8 +85,21 @@ export class OutboundDedupCache {
|
|
|
75
85
|
/** Record an outbound message. Caller should invoke this after a
|
|
76
86
|
* successful send, regardless of which path sent it (turn-flush,
|
|
77
87
|
* executeReply, executeStreamReply, etc.). Short content is not
|
|
78
|
-
* recorded — see DEDUP_MIN_CONTENT_LEN.
|
|
79
|
-
|
|
88
|
+
* recorded — see DEDUP_MIN_CONTENT_LEN.
|
|
89
|
+
*
|
|
90
|
+
* `turnKey` lets check() tell within-turn retries (the #546 race
|
|
91
|
+
* this module exists to catch) apart from cross-turn coincidences
|
|
92
|
+
* (a user asking similar questions in different turns). Pass
|
|
93
|
+
* `null` if the recording site has no turn context — that matches
|
|
94
|
+
* legacy behaviour and is what the early-boot / fallback callers
|
|
95
|
+
* pass. */
|
|
96
|
+
record(
|
|
97
|
+
chatId: string,
|
|
98
|
+
threadId: number | undefined,
|
|
99
|
+
text: string,
|
|
100
|
+
now: number,
|
|
101
|
+
turnKey: string | null = null,
|
|
102
|
+
): void {
|
|
80
103
|
if (text.length < DEDUP_MIN_CONTENT_LEN) return
|
|
81
104
|
const key = makeKey(chatId, threadId)
|
|
82
105
|
const list = this.entries.get(key) ?? []
|
|
@@ -85,6 +108,7 @@ export class OutboundDedupCache {
|
|
|
85
108
|
hash: normalizeForDedup(text),
|
|
86
109
|
ts: now,
|
|
87
110
|
preview: text.slice(0, 80),
|
|
111
|
+
turnKey,
|
|
88
112
|
})
|
|
89
113
|
this.entries.set(key, list)
|
|
90
114
|
}
|
|
@@ -92,12 +116,24 @@ export class OutboundDedupCache {
|
|
|
92
116
|
/** Check whether the given text was already sent recently to the
|
|
93
117
|
* same chat. Returns the matched entry's preview + age on hit, or
|
|
94
118
|
* null on miss. Caller decides what to do with the answer
|
|
95
|
-
* (skip-send, log, etc.).
|
|
119
|
+
* (skip-send, log, etc.).
|
|
120
|
+
*
|
|
121
|
+
* Cross-turn carve-out (2026-05-23 fix): when both sides of a hash
|
|
122
|
+
* match carry non-null `turnKey` AND those keys differ, treat as
|
|
123
|
+
* miss. The duplicate-reply race this module was built for (#546)
|
|
124
|
+
* is strictly within-turn (the same turn's buffered text replays
|
|
125
|
+
* via a stream_reply retry), so within-turn retries continue to
|
|
126
|
+
* hit. A user typing two similar prompts back-to-back used to lose
|
|
127
|
+
* the second turn's reply because the hashes collided across
|
|
128
|
+
* turns; that no longer happens. Null on EITHER side (legacy /
|
|
129
|
+
* no-turn-context callers) still matches — preserves backward
|
|
130
|
+
* compatibility with the original test suite + early-boot paths. */
|
|
96
131
|
check(
|
|
97
132
|
chatId: string,
|
|
98
133
|
threadId: number | undefined,
|
|
99
134
|
text: string,
|
|
100
135
|
now: number,
|
|
136
|
+
turnKey: string | null = null,
|
|
101
137
|
): { matched: true; preview: string; ageMs: number } | null {
|
|
102
138
|
if (text.length < DEDUP_MIN_CONTENT_LEN) return null
|
|
103
139
|
const key = makeKey(chatId, threadId)
|
|
@@ -106,9 +142,19 @@ export class OutboundDedupCache {
|
|
|
106
142
|
this.evict(list, now)
|
|
107
143
|
const candidateHash = normalizeForDedup(text)
|
|
108
144
|
for (const entry of list) {
|
|
109
|
-
if (entry.hash
|
|
110
|
-
|
|
145
|
+
if (entry.hash !== candidateHash) continue
|
|
146
|
+
// Cross-turn carve-out: distinct, non-null turnKeys on both
|
|
147
|
+
// sides ⇒ different turns ⇒ not a #546 retry. Skip past this
|
|
148
|
+
// entry and keep scanning (a same-turn match later in the list
|
|
149
|
+
// should still hit).
|
|
150
|
+
if (
|
|
151
|
+
turnKey != null
|
|
152
|
+
&& entry.turnKey != null
|
|
153
|
+
&& entry.turnKey !== turnKey
|
|
154
|
+
) {
|
|
155
|
+
continue
|
|
111
156
|
}
|
|
157
|
+
return { matched: true, preview: entry.preview, ageMs: now - entry.ts }
|
|
112
158
|
}
|
|
113
159
|
return null
|
|
114
160
|
}
|
|
@@ -142,6 +142,28 @@ export type RuntimeMetricEvent =
|
|
|
142
142
|
key: string
|
|
143
143
|
sinceFirstPingMs: number
|
|
144
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* Voice scrubber engaged: em / en dashes were rewritten to commas /
|
|
147
|
+
* periods on an outbound reply. Each event is a soft-layer policy
|
|
148
|
+
* violation the framework caught (SOUL.md.hbs "never use em-dashes"
|
|
149
|
+
* is the soft layer, this scrub is the hard layer). Fleet-wide
|
|
150
|
+
* trend over weeks shows whether the soft prompt is gaining or
|
|
151
|
+
* losing ground; a per-agent spike is prompt drift on that agent.
|
|
152
|
+
*
|
|
153
|
+
* chatKey → `<chatId>:<threadIdOrEmpty>` (statusKey shape)
|
|
154
|
+
* replaced → count of dashes rewritten in this single message
|
|
155
|
+
* site → which reply path saw the scrub (executeReply / edit / answer-stream)
|
|
156
|
+
*/
|
|
157
|
+
| {
|
|
158
|
+
kind: 'voice_scrub_applied'
|
|
159
|
+
chatKey: string
|
|
160
|
+
replaced: number
|
|
161
|
+
// `stream_reply` and `turn_flush` added in v0.13.21 — modern
|
|
162
|
+
// Claude routes most multi-paragraph replies through the
|
|
163
|
+
// answer-stream / draft-stream path, bypassing the v0.13.20
|
|
164
|
+
// executeReply scrub site. The two new sites close that gap.
|
|
165
|
+
site: 'reply' | 'edit_message' | 'progress_update' | 'answer_stream' | 'stream_reply' | 'turn_flush'
|
|
166
|
+
}
|
|
145
167
|
|
|
146
168
|
/**
|
|
147
169
|
* The JSONL sink lives under the runtime state dir so it's per-agent
|
|
@@ -459,7 +459,10 @@ function backfillJsonlAgentId(
|
|
|
459
459
|
log?.(`subagent-watcher: backfill linked ${agentId} → ${candidate.id}`)
|
|
460
460
|
}
|
|
461
461
|
|
|
462
|
-
|
|
462
|
+
// Exported for unit-testing the ENOENT/EACCES deregister path
|
|
463
|
+
// (telegram-plugin/tests/subagent-watcher-enoent-deregister.test.ts).
|
|
464
|
+
// Not intended for consumption by other modules.
|
|
465
|
+
export function readSubTail(
|
|
463
466
|
entry: WorkerEntry,
|
|
464
467
|
tail: SubTail,
|
|
465
468
|
now: number,
|
|
@@ -472,6 +475,14 @@ function readSubTail(
|
|
|
472
475
|
* previously-stalled entry. Closes the resume edge the schema doc
|
|
473
476
|
* has always promised. */
|
|
474
477
|
onUnstall?: (agentId: string, description: string) => void,
|
|
478
|
+
/** Fires when the JSONL file is no longer accessible (ENOENT — file
|
|
479
|
+
* reaped by Claude Code when the parent session ends; EACCES —
|
|
480
|
+
* permission change mid-poll). The caller deregisters the entry so
|
|
481
|
+
* the 1s poll loop stops re-statting a dead path. Without this
|
|
482
|
+
* callback, every poll re-emits the error log line — on 2026-05-23
|
|
483
|
+
* the clerk agent logged 540k ENOENT lines in 3 days (30/sec
|
|
484
|
+
* sustained) AND leaked one fs.watch FD per stranded entry. */
|
|
485
|
+
onFileVanished?: (agentId: string, code: 'ENOENT' | 'EACCES') => void,
|
|
475
486
|
): void {
|
|
476
487
|
try {
|
|
477
488
|
const stat = fs.statSync(entry.filePath)
|
|
@@ -639,6 +650,17 @@ function readSubTail(
|
|
|
639
650
|
}
|
|
640
651
|
tail.hasEmittedStart = startState.hasEmittedStart
|
|
641
652
|
} catch (err) {
|
|
653
|
+
const code = (err as NodeJS.ErrnoException).code
|
|
654
|
+
if (code === 'ENOENT' || code === 'EACCES') {
|
|
655
|
+
// JSONL is gone (Claude Code reaped the parent session's
|
|
656
|
+
// subagents/ dir) or permission flipped under us. Deregister the
|
|
657
|
+
// entry so the periodic poll stops re-emitting this same line
|
|
658
|
+
// forever. Logged ONCE per agent — operators can still audit
|
|
659
|
+
// which entries got reaped without 30 lines/sec of noise.
|
|
660
|
+
log?.(`subagent-watcher: JSONL vanished for ${entry.agentId} (${code}) — deregistering`)
|
|
661
|
+
onFileVanished?.(entry.agentId, code)
|
|
662
|
+
return
|
|
663
|
+
}
|
|
642
664
|
log?.(`subagent-watcher: read error ${entry.agentId}: ${(err as Error).message}`)
|
|
643
665
|
}
|
|
644
666
|
}
|
|
@@ -841,7 +863,7 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
841
863
|
if (!entry || !t) return
|
|
842
864
|
readSubTail(entry, t, nowFn(), (desc) => {
|
|
843
865
|
log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`)
|
|
844
|
-
}, fs, log, db, parentStateDir, config.onUnstall)
|
|
866
|
+
}, fs, log, db, parentStateDir, config.onUnstall, cleanupTerminalAgent)
|
|
845
867
|
maybySendStateTransition(agentId)
|
|
846
868
|
})
|
|
847
869
|
} catch (err) {
|
|
@@ -1179,7 +1201,7 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
1179
1201
|
if (!tail) continue
|
|
1180
1202
|
readSubTail(entry, tail, n, (desc) => {
|
|
1181
1203
|
log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`)
|
|
1182
|
-
}, fs, log, db, parentStateDir, config.onUnstall)
|
|
1204
|
+
}, fs, log, db, parentStateDir, config.onUnstall, cleanupTerminalAgent)
|
|
1183
1205
|
maybySendStateTransition(agentId)
|
|
1184
1206
|
}
|
|
1185
1207
|
|
|
@@ -142,3 +142,117 @@ describe('flushOnAgentDisconnect — registered agent disconnects (existing beha
|
|
|
142
142
|
expect(deps.activeDraftParseModes.size).toBe(0)
|
|
143
143
|
})
|
|
144
144
|
})
|
|
145
|
+
|
|
146
|
+
describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)', () => {
|
|
147
|
+
// The race that motivates this: the canonical reply path fires
|
|
148
|
+
// `setDone()` on the StatusReactionController BEFORE purgeReactionTracking
|
|
149
|
+
// runs `activeTurnStartedAt.delete(key)`. If the bridge crashes between
|
|
150
|
+
// those two steps, the controller loop sees an EMPTY activeStatusReactions
|
|
151
|
+
// (already cleared by setDone) but activeTurnStartedAt still has the key.
|
|
152
|
+
// Without the sweep, that key orphans and the next inbound is "held mid-
|
|
153
|
+
// turn" against a ghost.
|
|
154
|
+
|
|
155
|
+
it('sweeps activeTurnStartedAt keys the controller loop missed', () => {
|
|
156
|
+
// Construct the exact race: activeStatusReactions is EMPTY (setDone
|
|
157
|
+
// already cleared it on the reply path) but activeTurnStartedAt still
|
|
158
|
+
// has an entry.
|
|
159
|
+
const onDanglingTurnsSwept = vi.fn()
|
|
160
|
+
const clearActiveReactions = vi.fn()
|
|
161
|
+
const disposeProgressDriver = vi.fn()
|
|
162
|
+
const log = vi.fn()
|
|
163
|
+
const deps = {
|
|
164
|
+
agentName: 'clerk',
|
|
165
|
+
activeStatusReactions: new Map<string, FakeCtrl>(),
|
|
166
|
+
activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>([
|
|
167
|
+
['ghost:thr:msg', { chatId: 'ghost', messageId: 42 }],
|
|
168
|
+
]),
|
|
169
|
+
activeTurnStartedAt: new Map<string, number>([['ghost:thr:msg', 100]]),
|
|
170
|
+
activeDraftStreams: new Map<string, FakeStream>(),
|
|
171
|
+
activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
|
|
172
|
+
clearActiveReactions,
|
|
173
|
+
disposeProgressDriver,
|
|
174
|
+
onDanglingTurnsSwept,
|
|
175
|
+
log,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
flushOnAgentDisconnect(deps)
|
|
179
|
+
|
|
180
|
+
// The sweep fired and cleared the dangling entry.
|
|
181
|
+
expect(deps.activeTurnStartedAt.size).toBe(0)
|
|
182
|
+
expect(deps.activeReactionMsgIds.size).toBe(0)
|
|
183
|
+
expect(onDanglingTurnsSwept).toHaveBeenCalledTimes(1)
|
|
184
|
+
expect(onDanglingTurnsSwept.mock.calls[0][0]).toEqual(['ghost:thr:msg'])
|
|
185
|
+
// The log line names what happened so the operator can audit.
|
|
186
|
+
expect(
|
|
187
|
+
log.mock.calls.some((c: unknown[]) =>
|
|
188
|
+
typeof c[0] === 'string' && /swept .* dangling turn/.test(c[0]),
|
|
189
|
+
),
|
|
190
|
+
).toBe(true)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('does not fire the sweep when the controller loop already cleaned up everything', () => {
|
|
194
|
+
// Normal-path disconnect: activeStatusReactions had entries, the
|
|
195
|
+
// controller loop ran setDone + delete on each, activeTurnStartedAt
|
|
196
|
+
// is already empty by the end of the loop. No dangling to sweep.
|
|
197
|
+
const { spies, deps } = makeDeps('clerk')
|
|
198
|
+
const onDanglingTurnsSwept = vi.fn()
|
|
199
|
+
const depsWithCallback = { ...deps, onDanglingTurnsSwept }
|
|
200
|
+
|
|
201
|
+
flushOnAgentDisconnect(depsWithCallback)
|
|
202
|
+
|
|
203
|
+
// Controller loop already cleaned both entries.
|
|
204
|
+
expect(deps.activeTurnStartedAt.size).toBe(0)
|
|
205
|
+
// Callback NOT fired — nothing left to sweep after the loop.
|
|
206
|
+
expect(onDanglingTurnsSwept).not.toHaveBeenCalled()
|
|
207
|
+
// Regression: the existing setDone path still works.
|
|
208
|
+
expect(spies.setDoneA).toHaveBeenCalledTimes(1)
|
|
209
|
+
expect(spies.setDoneB).toHaveBeenCalledTimes(1)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('does NOT sweep for anonymous disconnects (no agent registered)', () => {
|
|
213
|
+
// Critical regression guard: the sweep MUST be gated by the
|
|
214
|
+
// agentName-null early-return. Anonymous one-shot IPC clients
|
|
215
|
+
// (recall.py, etc.) disconnect constantly and must never touch
|
|
216
|
+
// turn state.
|
|
217
|
+
const onDanglingTurnsSwept = vi.fn()
|
|
218
|
+
const deps = {
|
|
219
|
+
agentName: null,
|
|
220
|
+
activeStatusReactions: new Map<string, FakeCtrl>(),
|
|
221
|
+
activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
|
|
222
|
+
activeTurnStartedAt: new Map<string, number>([['real-turn:thr:msg', 100]]),
|
|
223
|
+
activeDraftStreams: new Map<string, FakeStream>(),
|
|
224
|
+
activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
|
|
225
|
+
clearActiveReactions: vi.fn(),
|
|
226
|
+
disposeProgressDriver: vi.fn(),
|
|
227
|
+
onDanglingTurnsSwept,
|
|
228
|
+
log: vi.fn(),
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
flushOnAgentDisconnect(deps)
|
|
232
|
+
|
|
233
|
+
// Anonymous disconnect: turn state preserved, sweep callback not fired.
|
|
234
|
+
expect(deps.activeTurnStartedAt.size).toBe(1)
|
|
235
|
+
expect(onDanglingTurnsSwept).not.toHaveBeenCalled()
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('omitting onDanglingTurnsSwept is safe (optional callback)', () => {
|
|
239
|
+
// Backward-compat guard — existing callers that don't pass the new
|
|
240
|
+
// callback still work without runtime error.
|
|
241
|
+
const deps = {
|
|
242
|
+
agentName: 'clerk',
|
|
243
|
+
activeStatusReactions: new Map<string, FakeCtrl>(),
|
|
244
|
+
activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
|
|
245
|
+
activeTurnStartedAt: new Map<string, number>([['ghost:thr:msg', 100]]),
|
|
246
|
+
activeDraftStreams: new Map<string, FakeStream>(),
|
|
247
|
+
activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
|
|
248
|
+
clearActiveReactions: vi.fn(),
|
|
249
|
+
disposeProgressDriver: vi.fn(),
|
|
250
|
+
// onDanglingTurnsSwept intentionally omitted.
|
|
251
|
+
log: vi.fn(),
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
expect(() => flushOnAgentDisconnect(deps)).not.toThrow()
|
|
255
|
+
// The sweep still happens, just without the callback observation.
|
|
256
|
+
expect(deps.activeTurnStartedAt.size).toBe(0)
|
|
257
|
+
})
|
|
258
|
+
})
|